DEV Community

Cover image for Hardening a Vercel app: CSP, CORS, and Service Workers that don’t bite
22s Pocket Portfolio
22s 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)