Frontend / Checklist
Security and privacy work the frontend owns
On a static or client-heavy site most of the security posture is configuration the frontend repo controls: transport, response headers, cookie flags, what the bundle leaks and what the forms accept. Every rule here names the header, flag or attribute to check, grouped by where the fix goes.
27 rules in 6 groups. Adapted from the Front-End Checklist (David Dias, MIT), rewritten and re-verified September 2026.
What this checklist decides
Whether the site can be downgraded, framed, scripted or mined for secrets by someone who never touches your backend. On Cloudflare Pages, Netlify, Vercel and every other static host nothing upstream sets these headers for you - if the repo does not ship them, nobody does, and "handled at the server level" describes a server that does not exist.
The privacy rows at the end are law as much as hygiene: consent, minimum data and a deletion path are what GDPR and the US state privacy acts require, and the frontend is where a visitor sees whether you meet them. Critical rows block the ship; High rows are the audit findings you will otherwise fix under a deadline.
- Critical blocks the ship
- High fix before launch
- Medium fix this quarter
- Low worth it when cheap
Transport
Three rules with no exceptions. Everything else on this page assumes the connection cannot be read or rewritten in transit.
| Rule | Priority | What to do |
|---|---|---|
| Redirect every HTTP request to HTTPS | Critical | A permanent 301 from http:// to the same path on https://, for the apex and every subdomain, before any content is served. On Cloudflare that is the "Always Use HTTPS" switch; elsewhere it is one rule in the redirect config. |
| Serve every page over HTTPS | Critical | A valid certificate on every hostname the site answers on, renewed automatically. Service workers, SameSite=None cookies, geolocation, HTTP/2 and HTTP/3 all refuse to work over plain HTTP, so this is a feature gate as much as a security one. |
| Remove mixed content | High | Every script, stylesheet, image, font, iframe and fetch on an HTTPS page loads over HTTPS. Browsers block mixed scripts outright and upgrade or block images; upgrade-insecure-requests in the CSP catches the ones you missed while you fix the source. |
Verify the first two rows from a terminal. One hop, permanent, straight to the final form of the URL - scheme, host and trailing slash all resolved at once:
$ curl -sI http://example.com/pricing | grep -iE "^(HTTP|location)"
HTTP/1.1 301 Moved Permanently
location: https://example.com/pricing/ Gotcha: the redirect and the certificate are usually the host's job, but the links are yours. A hard-coded http:// URL in a template, a CMS field or a third-party embed reintroduces mixed content on a site that was clean at launch. Grep the built output for http:// in CI.
Headers
Eight response headers that turn browser defaults into enforced policy. On a static host they come from a file in the repo, which means they are versioned, reviewable and testable like everything else.
| Rule | Priority | What to do |
|---|---|---|
| Ship a Content-Security-Policy | High | default-src 'self' plus an explicit allowance per resource type. Build script-src from hashes of your inline scripts and the third-party origins you actually load; allow 'unsafe-inline' only where a vendor script forces it, and say why in a comment. Start in Content-Security-Policy-Report-Only and read the reports before you enforce. |
| Set HSTS with preload | High | Strict-Transport-Security: max-age=31536000; includeSubDomains; preload on every HTTPS response, then submit the domain at hstspreload.org. Once preloaded the browser never sends an HTTP request to the domain again - which is why every subdomain must be HTTPS-ready first. |
| Set X-Content-Type-Options: nosniff | High | X-Content-Type-Options: nosniff stops the browser guessing a content type, so an uploaded file served as text/plain can never execute as script. There is no site that should leave it out. |
| Stop other sites framing yours | High | X-Frame-Options: DENY (or SAMEORIGIN when you frame your own pages) plus frame-ancestors 'none' in the CSP. The CSP directive wins where both are present; the older header covers clients that read only one. Clickjacking works on any page that can be framed. |
| Enable cross-origin isolation when you need it | Medium | Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp unlock SharedArrayBuffer and high-resolution timers, at the cost of every cross-origin resource needing Cross-Origin-Resource-Policy or CORS. Set them for an app that needs them; a content site gains nothing. |
| Open external links safely | Medium | Every target="_blank" link carries rel="noopener noreferrer". noopener stops the new page scripting window.opener; noreferrer also withholds the Referer header. Current browsers imply noopener, but the attribute costs nothing and the CSP does not cover it. |
| Set a Permissions-Policy | Medium | Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=() disables the browser features the site does not use, for the page and every iframe inside it. Add back only what a feature needs, scoped to self. |
| Set a Referrer-Policy | Medium | Referrer-Policy: strict-origin-when-cross-origin sends the full URL on same-origin requests, only the origin cross-origin, and nothing on a downgrade. It is the browser default now; setting it explicitly keeps it that way and stops a path or query string leaking to a third party. |
The whole set as a Cloudflare Pages _headers file - the same shape works for Netlify. The hash is computed from the built output of the one inline script the page carries; the analytics origin is the only third party allowed to run code:
/*
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-<base64 hash of the inline script>' https://www.clarity.ms; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://www.clarity.ms; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=() Gotcha: a hash-based script-src breaks the moment anything rewrites the inline script after the hash was taken - a minifier, a tag manager, a CDN feature that injects or reorders scripts. Generate the hashes from the built output as the last step, and verify the live headers after every deploy, not only in CI. Cloudflare Pages also drops any _headers line over 2,000 characters and stops after 100 rules, and only the deploy log says so.
Data and secrets
What ships to the visitor is public forever. These rows are about making sure nothing in that bundle - or in the code it depends on - should have stayed private.
| Rule | Priority | What to do |
|---|---|---|
| Keep secrets out of the bundle | Critical | Anything in a NEXT_PUBLIC_, VITE_ or PUBLIC_ variable ships to every visitor; anything else must never be imported from client code. Grep the built output for key shapes in CI, and rotate any key that has ever shipped - deleting it from the next build does not un-ship it. |
| Audit dependencies on every install | High | npm audit --audit-level=high (or the pnpm and yarn equivalents) in CI, a committed lockfile, Dependabot or Renovate opening the upgrade pull requests, and a written policy on what blocks a merge. A transitive dependency with a known exploit is your vulnerability. |
| Hide stack traces from users | High | Production error boundaries and API error bodies show a generic message and a correlation id, never the trace, the file path or the query. Send the detail to the error monitor, and upload source maps there rather than serving them publicly unless the code is open source anyway. |
A one-line build gate for the first row. Extend the pattern with your own providers' key prefixes; the point is that the build fails before the deploy, not that the list is complete:
# fail the build if a live key shape appears anywhere in the output
if grep -rEn "sk_live_|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY" dist/; then
echo "secret-shaped string found in dist/" && exit 1
fi Gotcha: a "public" API key is only safe when the provider scopes it by referrer or origin. A key that works from any origin - most analytics keys do, but many map, email and payment keys do not - is a secret with a misleading name. Check the provider's restriction settings before deciding a key may ship.
Authentication
The auth provider does most of the work; the frontend's job is to not undo it. Two rules cover how the session reaches the browser and where it lives once there.
| Rule | Priority | What to do |
|---|---|---|
| Flag every session cookie | High | Secure keeps the cookie off plain HTTP, HttpOnly keeps it away from document.cookie, SameSite=Lax withholds it on cross-site POSTs. Add the __Host- prefix to lock the domain and path as well; the browser rejects a prefixed cookie that is missing Secure or Path=/. |
| Store tokens in httpOnly cookies, not localStorage | High | A token in localStorage or sessionStorage is readable by any script on the page, including a compromised dependency; an HttpOnly cookie is not. Keep the session server-side behind a cookie id. If you must hold a short-lived access token in the client, keep it in memory and never persist it. |
The header the auth callback should set. Everything after the value is a flag the browser enforces without any client code:
Set-Cookie: __Host-session=opaque-random-value; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=604800 Gotcha: SameSite=Lax still sends the cookie on top-level GET navigations from other sites - that is what keeps links working - so it is not CSRF protection for a GET endpoint that changes state. Make every mutation a POST, and keep an origin check or a CSRF token on the server regardless.
Forms
A form is the one place a visitor hands you data on purpose. Where it goes, how the password field behaves, and what stops a bot from filling it a thousand times are all frontend decisions.
| Rule | Priority | What to do |
|---|---|---|
| Submit every form over HTTPS | Critical | The action URL is https:// or a relative path on an HTTPS page - never an http:// endpoint, which browsers flag as not secure on the field itself and which sends the payload in clear text. Check the handler URL, not only the page. |
| Make password fields behave | High | type="password" with autocomplete="current-password" or new-password so managers fill and generate, a show-password toggle that is a real button, no maxlength below 64, no paste blocking, and signup feedback that checks against breached-password lists instead of demanding symbols. |
| Protect public forms with a challenge | Medium | Cloudflare Turnstile is the low-friction option - invisible for most visitors, no image puzzles, a token you verify server-side on submit. hCaptcha and reCAPTCHA are the fallbacks where Turnstile is not an option. Pair any of them with rate limiting; a challenge alone does not stop a patient bot. |
The Turnstile pair - the widget in the page, the verification on the server. The token is single-use and short-lived, so verify it in the same request that handles the submission:
<!-- in the form, next to the submit button -->
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
// on the server, before doing anything with the submission
const token = formData.get("cf-turnstile-response");
const verify = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ secret: TURNSTILE_SECRET, response: token })
});
const { success } = await verify.json();
if (!success) return new Response("Challenge failed", { status: 403 }); Gotcha: a honeypot field is not a substitute for a challenge, and a challenge is not a substitute for server-side validation. The frontend can only make abuse expensive; the server has to refuse it. And the only proof a contact form works is a human confirming the message arrived - a 200 from the handler is not delivery.
Privacy and consent
What you collect, what you tell people, and what a visitor with a content blocker sees. The first two High rows are legal requirements in most markets; the rest are what keeps a privacy-conscious visitor on the site.
| Rule | Priority | What to do |
|---|---|---|
| Show a consent notice only if you set non-essential cookies | High | If the site sets analytics, advertising or personalization cookies - or equivalent storage - for EU or UK visitors, block them until the visitor opts in, make reject as easy as accept, and record the choice. If it sets none, show no banner: it is not required and it costs conversions. |
| Link the privacy policy from every page | High | A footer link on every page to a policy that says what you collect, why, who processes it, how long you keep it and how to exercise rights. The privacy laws, the app stores and every analytics vendor's terms all require it. |
| Link the terms of service from the footer | Medium | A footer link to the terms on every page, and an explicit statement or checkbox at signup that references them. Terms nobody could have seen are hard to enforce in most jurisdictions. |
| Collect the minimum | Medium | Every form field and every analytics property is a liability. Ask only for what the feature needs, drop the phone number field nobody calls, and turn off IP-level and user-id tracking in analytics unless something depends on them. |
| Give people a way to delete their data | Medium | A self-service delete-account button, or at minimum a documented request path with a stated turnaround, that removes the account and its records from your systems and your processors. GDPR and CCPA both require one. |
| Drop third-party cookies | Medium | Chrome walked back its plan to remove them, but Safari and Firefox block them, so anything that relies on one already breaks for those visitors. Use first-party cookies, server-side or proxied analytics, and check every embed - video, maps, social - for the cookies it sets. |
| Do not route links through tracking domains | Low | A link whose href points at a tracking or redirect domain is dead for every visitor with a content blocker. Link to the real destination and measure with a click event or a ping attribute the blocker can drop without breaking the link. |
| Avoid class names that content blockers hide | Low | Class names like ad, banner, sponsor and social-share match filter lists, and the blocker hides your own components. Name components by what they are to you, not by what they resemble. |
The outbound link that satisfies both the Headers row and the privacy rows: noreferrer withholds the Referer header, so the destination never learns which page - or which query string - the visitor came from:
<a href="https://example.com/report/" target="_blank" rel="noopener noreferrer">Read the full report</a> Gotcha: a consent banner that sets the analytics cookie before the visitor answers is worse than no banner - it documents that you knew. Test in a fresh browser profile: nothing non-essential appears under the storage panel until after the click, and the reject path leaves it that way.
Keep going
The Critical and High rows here sit on the launch checklist next to their siblings. The JavaScript checklist covers eval, cross-origin messaging and safe web storage, and the HTML checklist covers Subresource Integrity on the external scripts the CSP allows.
Which host ships the headers file, and how? The hosting picks compare them. Cookies and tokens are mostly decided by the auth provider - the auth picks and Clerk vs Auth.js cover that - and the dependency audit belongs in the CI/CD pipeline so it runs on every pull request rather than when someone remembers.