Browser-facing and cross-cutting web security skill for production applications. Covers: CSRF, XSS, CSP, cookie security, CORS, session management, authentication, JWT security, OAuth 2.1, SSRF, security headers, input validation, dependency scanning, and OWASP API Security Top 10. Use when: implementing auth, reviewing security posture, configuring CORS/CSP/cookies, or hardening endpoints against browser-based attacks. Sources: OWASP cheat sheets, Google BeyondCorp, Stripe, Cloudflare, Mozilla, Auth0, RFC 9700.
Cross-cutting browser-facing security guidance for production web applications. This skill deepens topics that span API design, frontend, and backend — CSRF, XSS, CSP, cookies, sessions, auth, JWT, OAuth 2.1, CORS, headers, SSRF, input validation, and supply chain security.
Based on OWASP cheat sheets (2024), Google BeyondCorp, Stripe security patterns, Cloudflare production configs, Mozilla Web Security Guidelines, Auth0/Okta best practices, the OAuth 2.1 draft (as of January 2025), and the OAuth 2.0 Security BCP (RFC 9700, January 2025).
Scope boundary: This skill covers what to enforce and why. For implementation:
- React patterns,
dangerouslySetInnerHTML,hrefvalidation → TypeScript skill (/typescript§11)- API contract decisions (error format, status codes, auth headers) → API Design skill (
/api-design§10-11)
| Attack | Vector | Target |
|---|---|---|
| CSRF | Forged cross-origin request with ambient cookies | Cookie-authenticated mutations |
| XSS | Injected script in HTML context | Session tokens, user data, DOM |
| Clickjacking | Transparent iframe overlay | User actions on framed page |
| SSRF | Server fetches attacker-controlled URL | Internal services, cloud metadata |
| CORS misconfiguration | Overly permissive origin policy | Cross-origin data leakage |
| Session fixation | Attacker sets victim's session ID | Account takeover |
| JWT confusion | Algorithm substitution or claim bypass | Authentication bypass |
| # | Risk | Key mitigation |
|---|---|---|
| API1 | Broken Object-Level Authorization | Check object ownership in every handler |
| API2 | Broken Authentication | Rate limit auth endpoints, enforce MFA |
| API3 | Broken Object Property-Level Authorization | Filter response fields by role |
| API4 | Unrestricted Resource Consumption | Rate limiting, pagination limits, payload size caps |
| API5 | Broken Function-Level Authorization | RBAC middleware on every route |
| API6 | Unrestricted Access to Sensitive Business Flows | Bot detection, CAPTCHA on sensitive actions |
| API7 | Server-Side Request Forgery | Input validation, private IP denylist, egress firewall |
| API8 | Security Misconfiguration | Security headers, disable debug endpoints, least privilege |
| API9 | Improper Inventory Management | API versioning, deprecation policy, endpoint registry |
| API10 | Unsafe Consumption of APIs | Validate third-party responses, timeout external calls |
See API Design skill (
/api-design§10) for contract-level auth patterns.
Signed double-submit cookie (primary defense)
HMAC(session_id, secret_key)X-CSRF-Token) or form fieldFetch Metadata validation (server-side, 98% browser coverage)
Sec-Fetch-Site: cross-site on state-changing endpointsSec-Fetch-Mode and Sec-Fetch-Dest for defense-in-depthCustom request headers (CORS-based defense)
X-Requested-With) on mutations<form> and <img> cannot set custom headersSameSite cookies (necessary but insufficient alone)
SameSite=Lax — the correct default for web apps. Blocks cross-site POST while preserving top-level GET navigations (OAuth callbacks, inbound links). Pair with CSRF tokens for full protectionSameSite=Strict — blocks all cross-site cookie sending. Use only for apps with no OAuth, no external redirects, and no inbound authenticated links. Not "more secure" than Lax + CSRF tokens — just more restrictiveOrigin / Referer verification (secondary check)
Origin header matches expected domain on mutationsReferer if Origin absentXSS defeats all CSRF protections. If an attacker can execute JavaScript on your origin, they can read CSRF tokens from the DOM or cookies. Fix XSS first.
Stripe pattern: use the state parameter as a CSRF token — bind to the user's session, verify on callback. One-time use, expire after 5 minutes.
| Context | Encoding | Example |
|---|---|---|
| HTML body | HTML entity encode & < > " ' | <p>Hello <script></p> |
| HTML attribute | Attribute encode (all non-alphanumeric as &#xHH;) | <input value=""injected"> |
| JavaScript string | JavaScript hex encode (\xHH) | var x = '\x3cscript\x3e' |
| URL parameter | URL encode (%HH) | ?q=%3Cscript%3E |
| CSS value | CSS hex encode (\HH) | background: \3cscript\3e |
<script> blocks (even encoded)onclick, onerror, onload)eval(), setTimeout(string), new Function(string)expression() or url() with user inputjavascript: URLs in href or srcdangerouslySetInnerHTML (sanitize with DOMPurify), href attributes (validate against javascript: URLs), ref callbacks with user datarequire-trusted-types-for 'script' — prevents DOM XSS at the browser levelSee TypeScript skill (
/typescript§11) for React-specific XSS patterns and<SafeHTML>component.
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{RANDOM}' 'strict-dynamic';
style-src 'self' 'nonce-{RANDOM}';
object-src 'none';
base-uri 'none';
form-action 'self';
frame-ancestors 'none';
<script nonce="..."> tagsstrict-dynamic: Allows scripts loaded by nonced scripts (dynamic imports, trusted loaders) without explicit allowlistingobject-src 'none': Blocks Flash/Java plugin abusebase-uri 'none': Prevents <base> tag injection (relative URL hijacking)frame-ancestors 'none': Replaces X-Frame-Options: DENY for clickjacking protectionContent-Security-Policy: default-src 'none'; frame-ancestors 'none'
APIs serve no HTML — lock everything down. Cloudflare recommends different header sets for API vs HTML responses.
unsafe-inline for scripts (defeats CSP purpose)unsafe-eval (allows eval() — XSS vector)* in script-src or default-srcContent-Security-Policy-Report-Only with report-to / Reporting-Endpoints (optionally add legacy report-uri for older browsers)eval() → alternatives)Content-Security-Policyreport-to reporting active for ongoing monitoring__Host-SESSION=<value>; Path=/; Secure; HttpOnly; SameSite=Lax
| Prefix | Requirements | Use for |
|---|---|---|
__Host- | Secure, no Domain, Path=/ | Session cookies (strictest — prevents subdomain attacks) |
__Secure- | Secure only | Cookies that need subdomain sharing |
| Attribute | Value | Why |
|---|---|---|
Secure | (flag) | HTTPS only — prevents network sniffing |
HttpOnly | (flag) | No JavaScript access — mitigates XSS token theft |
SameSite | Lax | CSRF mitigation — Lax is the correct default for web apps with OAuth or external links. Strict is a niche choice for closed apps with no external auth flows (see §2) |
Path | / | Scope to entire site |
Max-Age or Expires — cookie dies with browser session| Location | Pros | Cons | Recommendation |
|---|---|---|---|
HttpOnly cookie | XSS-proof, auto-sent | CSRF risk (mitigate per §2) | Preferred for auth |
localStorage | Simple API | XSS reads it directly | Never for auth tokens |
sessionStorage | Tab-scoped | XSS reads it, lost on tab close | Never for auth tokens |
| Web Worker | No DOM access | Complex setup | Acceptable for SPAs |
Backend-for-Frontend: the frontend never sees tokens. The backend holds access/refresh tokens in HttpOnly cookies, proxies API calls with Bearer tokens attached server-side. Eliminates frontend token storage concerns entirely.
See API Design skill (
/api-design§10) for token type decisions (JWT vs opaque).
JSESSIONID, PHPSESSID, etc.)| Type | High-value apps | Low-risk apps |
|---|---|---|
| Idle timeout | 2-5 minutes | 15-30 minutes |
| Absolute timeout | 4-8 hours | 12-24 hours |
Server-side destruction is mandatory — expiring the cookie alone is insufficient:
Set-Cookie with Max-Age=0)Session theft is invisible without anomaly detection — a stolen session cookie works silently until it expires. For apps handling financial data, PII, or privileged operations, detecting anomalies is how you catch compromised sessions before damage is done.
Monitor these attributes and trigger step-up authentication (not hard lockout) when they change mid-session:
Why step-up, not hard binding: mobile networks, VPNs, and IPv6 privacy extensions cause legitimate IP changes. Hard binding locks out real users. Step-up auth (re-enter password, second factor) confirms identity without blocking access.
Require fresh authentication before:
| Rule | Value |
|---|---|
| Minimum length | 8 chars (with MFA) / 15 chars (without) |
| Maximum length | 64+ characters (never truncate) |
| Composition rules | None — no uppercase/special char requirements |
| Rotation | Never require periodic rotation |
| Breached check | Check against known breached databases (HIBP API) |
| Feedback | Show real-time strength meter based on entropy |
All authentication failure responses must be identical in:
See API Design skill (
/api-design§10) for auth header patterns and token types.
alg: none — disable in JWT library configurationalg header| Claim | Check | On failure |
|---|---|---|
iss (issuer) | Exact match against known issuer | Reject |
aud (audience) | Must contain this service's identifier | Reject |
exp (expiration) | Current time < exp (with clock skew tolerance) | Reject |
nbf (not before) | Current time >= nbf | Reject |
iat (issued at) | Reasonable recency check | Reject |
sub (subject) | Valid user identifier format | Reject |
| Token | Lifetime | Storage |
|---|---|---|
| Access token | 15-30 minutes | HttpOnly cookie or memory |
| Refresh token (absolute) | 30 days max | HttpOnly cookie, server-side record |
| Refresh token (idle) | 7 days | Revoke if unused |
HttpOnly cookiejti claim)exp passesOAuth 2.1 is a draft that consolidates secure OAuth 2.0 patterns. RFC 9700 is a separate document, OAuth 2.0 Security Best Current Practice, which provides guidance for existing OAuth 2.0 deployments rather than defining OAuth 2.1 itself.
Removed:
response_type=token) — tokens in URL fragments are insecure?access_token=...) — logged in server access logsRequired:
code_verifier (43-128 chars, [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~")code_challenge = BASE64URL(SHA256(code_verifier))code_challenge + code_challenge_method=S256code_verifier — server verifies against stored challengeplain method — always S256state parameter must be one-time-use CSRF token bound to sessionOAuth redirect URIs are validated by the provider, but applications often build their own redirect flows (e.g., returnTo after login, post-action redirects). These are equally dangerous if unsanitized.
Rules:
Location header derived from user input must be validated///evil.com) and backslash-relative URLs (/\evil.com — some URL parsers normalize \ to /, resolving it as //evil.com)https://...), javascript: URIs, and data: URIs/) when validation fails — never echo the invalid inputDefense-in-depth: Sanitize at every trust boundary the value crosses:
Location header)Redundant sanitization is cheap. A single missed boundary is an open redirect.
Anti-patterns:
Location: ${req.query.returnTo} — raw user input in redirect headerstartsWith("/") without also checking // and /\ — incomplete guardAccess-Control-Allow-Origin: * with credentialsOrigin header against allowlist on every request (not just preflight)Access-Control-Max-Age (e.g., 7200 seconds) to reduce preflight requestsAccess-Control-Allow-Credentials: true requires a specific origin (not *)Origin header back as Access-Control-Allow-Origin without validation (allows any origin)null origin (local files, sandboxed iframes can send Origin: null)evil.sub.example.com compromises api.example.com)Access-Control-Expose-Headers unnecessarilyContent-Security-Policy script/style directives, X-Frame-Options, frame-ancestors), but still include: X-Content-Type-Options: nosniff, Strict-Transport-Security, Referrer-Policy, and CORS headers| Header | Value | Notes |
|---|---|---|
Strict-Transport-Security | max-age=63072000; includeSubDomains | 2 years, all subdomains. Add preload only after confirming all subdomains support HTTPS — removal from the preload list takes months |
X-Content-Type-Options | nosniff | Prevents MIME-type sniffing |
X-Frame-Options | DENY | Clickjacking defense (pair with CSP frame-ancestors) |
Referrer-Policy | strict-origin-when-cross-origin | Send origin only on cross-origin, full URL same-origin |
Cross-Origin-Opener-Policy | same-origin | Isolates browsing context from cross-origin popups |
Cross-Origin-Embedder-Policy | require-corp | Enables SharedArrayBuffer, cross-origin isolation |
Cross-Origin-Resource-Policy | same-site | Prevents cross-site embedding of resources |
Permissions-Policy | geolocation=(), camera=(), microphone=(), interest-cohort=() | Disable unused browser features |
X-XSS-Protection | 0 | Auditor removed from all browsers — disable to avoid false positives |
Content-Type | Include charset: application/json; charset=UTF-8 | Prevents charset-based XSS |
| Header | Why |
|---|---|
Server | Leaks server software and version |
X-Powered-By | Leaks framework (Express, Rails, etc.) |
Expect-CT | Deprecated — Certificate Transparency is now enforced by default |
Public-Key-Pins | Deprecated — risk of bricking sites, replaced by CT |
10.0.0.0/8 (RFC 1918)172.16.0.0/12 (RFC 1918)192.168.0.0/16 (RFC 1918)127.0.0.0/8 (loopback)169.254.0.0/16 (link-local, including cloud metadata at 169.254.169.254)::1, fc00::/7, fe80::/10 (IPv6 equivalents)file://, gopher://, dict://)| Check | Rule |
|---|---|
| Length | Enforce min and max (prevent buffer abuse, empty strings) |
| Charset | Allowlist valid characters for the field |
| Unicode | Normalize (NFKC) before validation (prevents homograph attacks) |
| Regex | Always anchor: ^pattern$ (unanchored regex matches substrings) |
| ReDoS | Test regex patterns for catastrophic backtracking — avoid nested quantifiers (a+)+ |
| Check | Rule |
|---|---|
| Extension | Allowlist (jpg, png, pdf) — never denylist |
| Filename | Rename to random UUID (prevents path traversal) |
| Content-Type | Verify magic bytes match declared type |
| Size | Enforce max file size at web server level (before application) |
| Storage | Store outside web root, serve via controlled endpoint |
| Malware | Scan with antivirus on upload |
Object.hasOwn() or Map for lookups, never bare record[key] ?? fallback — prototype keys like constructor or __proto__ bypass the fallbackSee TypeScript skill (
/typescript§11) for framework-level input handling.
| Tool | Language | Run in CI |
|---|---|---|
yarn audit | JavaScript/TypeScript | Every PR |
yarn.lock, Cargo.lock) — ensures reproducible buildssk_live_, sk_test_, pk_live_, pk_test_<script src="https://cdn.example.com/lib.js"
integrity="sha384-{hash}"
crossorigin="anonymous"></script>
shasum -b -a 384 lib.js | awk '{ print $1 }' | xxd -r -p | base64See TypeScript skill (
/typescript§11) for npm-specific patterns.