Security Audit
Purpose
Identify and fix frontend-side security vulnerabilities using OWASP Top 10 as the checklist. Defense-in-depth: no single fix solves all attacks — combine output encoding, CSP, sanitization, and server-side enforcement.
Universal — OWASP, CSP, RLS, env-leak detection, and auth-token storage rules apply to all stacks; only the framework-specific syntax differs (NEXT_PUBLIC_ vs VITE_ vs PUBLIC_).
Procedure
Context-aware output encoding (OWASP core teaching)
- The right encoding depends on the context where untrusted data lands:
- HTML body: React auto-escapes when used as JSX text content (
<div>{userInput}</div>is safe) - HTML attribute: React escapes for attribute values; danger appears in template-literal class names or
styleprops - JavaScript context: never inject untrusted data into
<script>,eval(),setTimeout(string) - CSS context: never inject into
styleattribute as a string — use object syntax - URL context: validate URL schemes (reject
javascript:/data:for hrefs); allowlist redirect targets — a?redirect=/returnToparam fed intorouter.push()or<a href>is an open-redirect (phishing) vector
- HTML body: React auto-escapes when used as JSX text content (
- React's auto-escape covers HTML body but NOT the other 4 contexts — explicit handling required
- The right encoding depends on the context where untrusted data lands:
Sanitize any raw-HTML injection sink before render — better, avoid raw-HTML injection entirely (use text content)
- Locate every raw-HTML sink and confirm each sanitizes untrusted input before it is written to the DOM
- Best: avoid the raw-HTML path altogether by rendering as escaped text content
- (React
dangerouslySetInnerHTML+ DOMPurify — see Implementation)
Never put secrets in client-bundled env vars; audit the client-prefixed vars for keys/tokens
- Any env var with the client-bundle prefix is shipped to the browser — never put secrets there
- Audit every client-prefixed var → verify no API keys, tokens, or secrets leak into the bundle
- Server-only secrets: unprefixed env vars, accessed only in server-side code
- Also audit other client-side leaks: production source maps served publicly (exposes original source), PII/tokens in
console.log, secrets passed in URL query params (logged in server/referrer/analytics) - (Next
NEXT_PUBLIC_*— see Implementation)
Audit token storage
- Banned: auth tokens in
localStorageorsessionStorage(XSS-readable) - Required: HttpOnly + Secure + SameSite cookies for auth tokens
- Audit:
grep -rn 'localStorage.*token\|sessionStorage.*token' src/
- Banned: auth tokens in
4b. Protect cookie-based auth against CSRF
- Cookie auth (step 4) is sent automatically on cross-site requests → CSRF risk.
SameSite=Lax(orStrict) blocks most cases but NOT all (SameSite=Noneintegrations, some cross-site POST contexts) - For state-changing requests, also verify the request origin server-side: a CSRF token (double-submit) or an
Origin/Sec-Fetch-Sitecheck - Header-based tokens (
Authorization) aren't CSRF-prone — but they live in JS memory, so don't reintroduce the localStorage problem from step 4
Detect code-execution sinks
eval(),new Function()— should never appearsetTimeout('string'),setInterval('string')(string form, not function form)- Search and eliminate
Verify CSP headers
Content-Security-Policyset with at minimum:script-src 'self' [trusted CDNs](no'unsafe-inline'or'unsafe-eval')object-src 'none'frame-ancestors 'none'(or specific allowlist)
- For inline scripts (required by Next.js): use nonce-based CSP
- Enable Trusted Types where supported (Chrome/Edge):
Forces all DOM sink writes (innerHTML, etc.) to go through a Trusted Types policy.Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default - Add Subresource Integrity (
integrity+crossorigin) to third-party CDN<script>/<link>so a compromised CDN can't swap in malicious code (coordinate withthird-party-scripts)
Enforce authentication AND authorization server-side — client checks are decorative/bypassable
- Authentication (who are you): a client-only
if (!user) redirect()is always bypassable; the server must verify the session on every protected route - Authorization (can you touch this resource): OWASP's #1 category. Even when authenticated, verify object ownership on every request —
/api/orders/123must confirm order 123 belongs to the caller (IDOR / broken object-level access control). The UI hiding a link is not access control - For Supabase, RLS (step 8) enforces ownership at the DB; for custom APIs the per-resource ownership check is explicit in each handler
- Audit all protected routes/handlers for both checks
- (
requireUser()+ per-resource ownership check — see Implementation)
- Authentication (who are you): a client-only
Enable row-level security on every user-data table; verify policies aren't
using (true); keep public tables few- All user-data tables must have row-level security enabled
- Verify policies are correct (not an open
using (true)) - List public tables explicitly — there should be very few
- (Supabase RLS — see Implementation)
CORS audit
- No wildcard
Access-Control-Allow-Origin: *for endpoints that accept credentials - Allowlist specific origins
- No wildcard
Validate env at build via a typed schema (fail the build, not production runtime)
- Validate env against a typed schema so missing or malformed values fail the build, not production at runtime
- (
@t3-oss/env-nextjs— see Implementation)
- Verify with automated tooling (validation loop)
- Run a security linter over the codebase; if violations, evaluate each (true positive → fix; false positive → suppress inline with a comment) and re-run until clean
- Run a dependency vulnerability scan; if Critical/High vulnerabilities, patch (auto-fix or manual version bump) and re-run; if no patch available, document and accept the risk with reason
- Re-test all fixes — security fixes that break functionality are not fixes
- (eslint-plugin-security /
npm audit— see Implementation)
Anti-patterns
| ❌ Anti-pattern | ✅ Correct |
|---|---|
Auth token in localStorage |
HttpOnly + Secure + SameSite cookie |
NEXT_PUBLIC_API_SECRET in env |
Server-only env (no NEXT_PUBLIC_ prefix) |
dangerouslySetInnerHTML={{__html: userInput}} |
DOMPurify.sanitize(userInput) first, or refactor to JSX text |
Client-only if (!user) redirect() |
Server-side requireUser() in Server Component / Route Handler |
/api/orders/:id returns any user's order |
Verify object ownership per request (RLS or explicit check) |
| State-changing cookie request with no CSRF defense | SameSite + Origin/Sec-Fetch-Site or CSRF token |
router.push(searchParams.get('redirect')) |
Allowlist redirect targets |
Access-Control-Allow-Origin: * for credentialed endpoints |
Explicit origin allowlist |
process.env.X accessed directly |
Typed env.X via @t3-oss/env-nextjs |
Severity tiers
| Tier | Examples | Action SLA |
|---|---|---|
| Critical | Auth token in localStorage; missing RLS on user-data table; broken object-level access control (IDOR — user reads/modifies another user's data); eval() on user input |
Block release; fix immediately |
| Major | No CSRF defense on cookie-auth state-changing requests; open redirect via unvalidated param; missing CSP script-src restriction; CORS wildcard; NEXT_PUBLIC_* leak |
Fix this sprint |
| Minor | Missing frame-ancestors; public production source maps; missing SRI on CDN scripts; outdated dep with no known exploit |
Schedule within 2 sprints |
Completion Criteria
- All
dangerouslySetInnerHTMLsanitized via DOMPurify - No secrets in
NEXT_PUBLIC_*env vars - No auth tokens in localStorage / sessionStorage
- Cookie-auth state-changing requests have CSRF protection (
SameSite+ origin/token check) - CSP headers configured (no
unsafe-inline/unsafe-evalforscript-src) - All protected routes verified server-side (authentication)
- Object-level authorization checked server-side (no IDOR) — not just authentication
- Redirect /
returnToparams allowlisted (no open redirect) - RLS enabled on all user-data Supabase tables
- Env variables validated via Zod schema
- All Critical findings fixed; all Major findings scheduled
Stop & Ask (AI must pause for user approval)
- Before applying any Critical fix that touches auth, RLS, or session handling — these changes can lock users out if wrong
- Before changing CSP headers in production-affecting config (one bad nonce = blocked scripts)
- Before bulk dependency upgrades triggered by
npm audit fix(can introduce breaking changes)
Output
- Report:
docs/security-audit-YYYY-MM-DD.mdwith sections:## Summary— counts by tier (Critical / Major / Minor)## Critical findings— per finding: file:line, category, exploit scenario, fix## Major findings— same format## Minor findings— same format## Verification— tools used, commands run, validation loop results
- Code changes: separate commits per fix; commit message format
fix(security): <description> [severity: critical|major|minor] - CSP header config: update in
next.config.ts(or equivalent) with a comment linking to the audit report
Implementation
React + Next.js (default)
- Client-bundled env prefix:
NEXT_PUBLIC_*— never put secrets here - Sanitization:
DOMPurifyfordangerouslySetInnerHTML - Server enforcement: Server Components / Route Handlers / Server Actions with
requireUser()middleware - Authorization: check
resource.userId === session.user.idin every handler (no IDOR); RLS for Supabase tables - CSRF:
SameSite=Lax/Strictcookies + verifyOrigin/Sec-Fetch-Site(or a CSRF token) in Server Actions / Route Handlers for mutations - Source maps: keep
productionBrowserSourceMaps: false(default) — don't ship original source to the public - CSP:
next.config.tsheaders()function; nonce-based for inline scripts - Env validation:
@t3-oss/env-nextjs(Zod schemas) - Database: Supabase RLS (Row Level Security policies)
Other stacks
- Vue / Nuxt: client-bundled prefix
NUXT_PUBLIC_*; sanitizationv-html+ DOMPurify; CSP vianuxt.config.tsrouteRules - SvelteKit: client-bundled prefix
PUBLIC_*; sanitization in+page.sveltewith DOMPurify; CSP viasvelte.config.jscspoption - Vite (any framework): client-bundled prefix
VITE_* - Angular: built-in sanitization via
DomSanitizer(don't bypass); CSP via meta tag or server header - All stacks: HttpOnly + Secure + SameSite cookies for auth tokens; never localStorage
- OWASP categories (LLM, OAuth, API) — framework-independent; apply directly
Related skills
accessibility-audit— some aria attrs (aria-*) carry security weight toodeveloper-experience— env validation (@t3-oss/env-nextjs) is set up therethird-party-scripts— SRI, CSP, and consent for external/CDN scriptscicd-pipeline— wirenpm auditand CSP regression tests into CI
Reference
- Key insight encoded: Output encoding alone is insufficient — combine context-aware escaping, DOMPurify sanitization, nonce-based CSP, and Trusted Types as defense-in-depth. Client-side protection is decorative; server-side enforcement (RLS,
requireUser(), env validation at build) is what actually secures the application. Two easily-missed gaps: cookie auth (the recommended token storage) needs CSRF defense (SameSite+ origin/token), and authentication ≠ authorization — verify object ownership per request or you ship IDOR (OWASP's #1).