DEV Community

Cover image for Hardening a Vercel app: CSP, CORS, and Service Workers that don’t bite
Pocket Portfolio
Pocket Portfolio

Posted on

Hardening a Vercel app: CSP, CORS, and Service Workers that don’t bite


We just shipped the MVP of **Pocket Portfolio** (OSS, privacy-first). This post shows the exact **CSP**, **CORS**, and **Service Worker** setup we used to keep things fast *and* safe on Vercel + Firebase.

> TL;DR: Lock down third-party origins, cache UI not money, and never let your SW hijack `/api/*`.

---

## 1) Content Security Policy (CSP)

Our policy lives in `vercel.json` headers. The key is allowing what Firebase Auth *actually* uses (`apis.google.com`, `accounts.google.com`, `gstatic`) and any CDNs you intentionally rely on.

```

json
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Content-Security-Policy",
          "value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://www.gstatic.com https://*.googleapis.com https://apis.google.com https://accounts.google.com; script-src-elem 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://www.gstatic.com https://*.googleapis.com https://apis.google.com https://accounts.google.com; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data: https:; connect-src 'self' https://www.googleapis.com https://*.googleapis.com https://securetoken.google.com https://identitytoolkit.googleapis.com https://firestore.googleapis.com https://*.firebaseio.com https://firebasestorage.googleapis.com https://apis.google.com https://accounts.google.com; frame-src 'self' https://*.google.com https://accounts.google.com https://*.firebaseapp.com https://*.web.app; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests"
        }
      ]
    }
  ]
}


Enter fullscreen mode Exit fullscreen mode

Why:

  • Blocks surprise script loads and XSS fallout.
  • Lets Google Sign-in popups/iframes work in prod (no mysterious 400s).

2) CORS for your Serverless/Edge APIs

Expose only what the browser needs and only to your site.


js
// /api/_cors.js
export const cors = (req, res, { methods = ["GET"], origin = "https://pocketportfolio.app" } = {}) => {
  res.setHeader("Access-Control-Allow-Origin", origin);
  res.setHeader("Vary", "Origin");
  res.setHeader("Access-Control-Allow-Methods", methods.join(","));
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
  if (req.method === "OPTIONS") { res.status(204).end(); return true; }
  return false;
};


Enter fullscreen mode Exit fullscreen mode

Use it:


js
// /api/quote.js
import { cors } from "./_cors.js";

export default async function handler(req, res) {
  if (cors(req, res)) return;              // preflight handled
  const { ticker } = req.query || {};
  if (!/^[A-Z.\-]{1,7}$/.test(ticker || "")) {
    res.status(400).json({ error: "bad ticker" });
    return;
  }
  // fetch upstream → normalize → respond
  res.setHeader("Cache-Control", "public, max-age=5, stale-while-revalidate=25");
  res.status(200).json({ price: 123.45, ts: Date.now() });
}


Enter fullscreen mode Exit fullscreen mode

3) A Service Worker that doesn’t break your app

Cache the shell (CSS/JS/icons) and navigations in /app/*. Never intercept /api/*.


js
/* /app/service-worker.js */
const SW_VERSION = "pp-v9";
const SHELL = [
  "/app/", "/app/index.html", "/app/style.css", "/app/app.js",
  "/app/manifest.webmanifest", "/brand/pp-monogram.svg"
];

self.addEventListener("install", (e) => {
  e.waitUntil(caches.open(SW_VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
});

self.addEventListener("activate", (e) => {
  e.waitUntil(
    caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== SW_VERSION).map((k) => caches.delete(k))))
      .then(() => self.clients.claim())
  );
});

self.addEventListener("fetch", (e) => {
  const url = new URL(e.request.url);
  if (url.origin !== location.origin) return;           // ignore third-party
  if (!url.pathname.startsWith("/app/")) return;        // keep scope tight
  if (url.pathname.startsWith("/api/")) return;         // never cache APIs

  // Static assets → cache-first
  if (/\.(css|js|mjs|map|svg|png|jpg|jpeg|webp|ico|woff2?)$/i.test(url.pathname)) {
    e.respondWith(
      caches.match(e.request).then((hit) =>
        hit ||
        fetch(e.request).then((res) => {
          caches.open(SW_VERSION).then((c) => c.put(e.request, res.clone()));
          return res;
        })
      )
    );
    return;
  }

  // Navigations → network-first, cache fallback
  if (e.request.method === "GET") {
    e.respondWith(
      fetch(e.request).then((res) => {
        caches.open(SW_VERSION).then((c) => c.put(e.request, res.clone()));
        return res;
      }).catch(() => caches.match(e.request).then((hit) => hit || caches.match("/app/index.html")))
    );
  }
});


Enter fullscreen mode Exit fullscreen mode

Register only in prod:


html
<script>
if ("serviceWorker" in navigator && !/localhost|127\.0\.0\.1/.test(location.hostname)) {
  navigator.serviceWorker.register("/app/service-worker.js?sw=9").catch(()=>{});
}
</script>


Enter fullscreen mode Exit fullscreen mode

4) Extra hardening (drop-ins)

  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-Policy: geolocation=(), microphone=(), camera=()
  • Asset caching:Cache-Control: public, max-age=31536000, immutable`
  • Rate limits for hot endpoints (Edge middleware or util)

What we’re building

Pocket Portfolio is an OSS, broker-free portfolio tracker. Add trades or import a small CSV. Live prices, P/L, clean UI.

Not investment advice. For research/education only.

Top comments (0)