# Security Audit

> OWASP Top 10 audit for frontend — XSS via dangerouslySetInnerHTML, env-var leaks, token storage, CSRF, broken access control (IDOR), open redirect, CSP, Supabase RLS, CORS, Zod env validation. Use when adding auth, after handling external input, before shipping, or quarterly. Not for the initial env-validation setup (use developer-experience) or wiring npm audit / CSP regression tests into CI (use cicd-pipeline).

- Skill: `jaykim88/security-audit` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jaykim88/security-audit`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jaykim88/security-audit/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: MIT
- Author: JayKim88 (https://skillmd.com/u/jaykim88)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/jaykim88/security-audit

---


# 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

1. **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 `style` props
     - **JavaScript context**: never inject untrusted data into `<script>`, `eval()`, `setTimeout(string)`
     - **CSS context**: never inject into `style` attribute as a string — use object syntax
     - **URL context**: validate URL schemes (reject `javascript:` / `data:` for hrefs); **allowlist redirect targets** — a `?redirect=` / `returnTo` param fed into `router.push()` or `<a href>` is an open-redirect (phishing) vector
   - React's auto-escape covers HTML body but NOT the other 4 contexts — explicit handling required

2. **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)

3. **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)

4. **Audit token storage**
   - **Banned**: auth tokens in `localStorage` or `sessionStorage` (XSS-readable)
   - **Required**: HttpOnly + Secure + SameSite cookies for auth tokens
   - Audit: `grep -rn 'localStorage.*token\|sessionStorage.*token' src/`

4b. **Protect cookie-based auth against CSRF**
   - Cookie auth (step 4) is sent automatically on cross-site requests → CSRF risk. `SameSite=Lax` (or `Strict`) blocks most cases but NOT all (`SameSite=None` integrations, 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-Site` check
   - Header-based tokens (`Authorization`) aren't CSRF-prone — but they live in JS memory, so don't reintroduce the localStorage problem from step 4

5. **Detect code-execution sinks**
   - `eval()`, `new Function()` — should never appear
   - `setTimeout('string')`, `setInterval('string')` (string form, not function form)
   - Search and eliminate

6. **Verify CSP headers**
   - `Content-Security-Policy` set 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):
     ```
     Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default
     ```
     Forces all DOM sink writes (innerHTML, etc.) to go through a Trusted Types policy.
   - Add Subresource Integrity (`integrity` + `crossorigin`) to third-party CDN `<script>`/`<link>` so a compromised CDN can't swap in malicious code (coordinate with `third-party-scripts`)

7. **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/123` must 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)

8. **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)

9. **CORS audit**
   - No wildcard `Access-Control-Allow-Origin: *` for endpoints that accept credentials
   - Allowlist specific origins

10. **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)

11. **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 `dangerouslySetInnerHTML` sanitized 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-eval` for `script-src`)
- [ ] All protected routes verified server-side (authentication)
- [ ] Object-level authorization checked server-side (no IDOR) — not just authentication
- [ ] Redirect / `returnTo` params 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.md` with 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: `DOMPurify` for `dangerouslySetInnerHTML`
- Server enforcement: Server Components / Route Handlers / Server Actions with `requireUser()` middleware
- Authorization: check `resource.userId === session.user.id` in every handler (no IDOR); RLS for Supabase tables
- CSRF: `SameSite=Lax`/`Strict` cookies + verify `Origin` / `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.ts` `headers()` 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_*`; sanitization `v-html` + DOMPurify; CSP via `nuxt.config.ts` `routeRules`
- **SvelteKit**: client-bundled prefix `PUBLIC_*`; sanitization in `+page.svelte` with DOMPurify; CSP via `svelte.config.js` `csp` option
- **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 too
- `developer-experience` — env validation (`@t3-oss/env-nextjs`) is set up there
- `third-party-scripts` — SRI, CSP, and consent for external/CDN scripts
- `cicd-pipeline` — wire `npm audit` and 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).

