| name | security-toolkit |
| description | Unified web security toolkit — OWASP Top 10 prevention, dependency vulnerability scanning, HTTP security headers (HSTS, X-Frame-Options, Permissions-Policy, CORP/COEP), encryption and key management, Content Security Policy (nonce, report-uri), CORS configuration, CSRF protection, XSS prevention, webhook signature verification. Keeps auth providers (clerk/better-auth/lucia/next-auth/oauth) separate. |
| layer | domain |
| category | security |
| triggers | ["OWASP check","access-control","aes","check for vulnerabilities","coep","content security policy","corp","cors","cors error","cors headers","cross-origin","cryptography","csp","csp header","csrf","decrypt","dependency audit","digital signature","encrypt","encryption","harden this","hashing","hmac","hsts","is this secure","key management","nonce","owasp","permissions policy","preflight","referrer policy","rsa","script-src","secret scan","security audit","security headers","security scan","sql injection","vulnerability","web security","x-frame-options","xss","xss prevention"] |
security-toolkit
Unified web security toolkit — OWASP Top 10 prevention, dependency vulnerability scanning, HTTP security headers (HSTS, X-Frame-Options, Permissions-Policy, CORP/COEP), encryption and key management, Content Security Policy (nonce, report-uri), CORS configuration, CSRF protection, XSS prevention, webhook signature verification. Keeps auth providers (clerk/better-auth/lucia/next-auth/oauth) separate.
Absorbs
owasp
security-scanner
security-headers
encryption
csp
cors
csrf-protection
xss-prevention
webhook-security
From owasp
OWASP Top 10 prevention, security headers, CSP, input validation, XSS/CSRF/SQLi protection
OWASP Security Specialist
Purpose
Prevent the OWASP Top 10 vulnerabilities in web applications. This skill covers security headers, Content Security Policy (CSP), input validation, output encoding, CSRF protection, SQL injection prevention, XSS mitigation, and security hardening checklists.
Key Patterns
OWASP Top 10 (2021) Quick Reference
| # | Risk | Primary Defense |
|---|
| A01 | Broken Access Control | RBAC, server-side checks, deny by default |
| A02 | Cryptographic Failures | TLS, AES-GCM, Argon2id, no plaintext secrets |
| A03 | Injection (SQL, XSS, etc.) | Parameterized queries, output encoding, CSP |
| A04 | Insecure Design | Threat modeling, secure design patterns |
| A05 | Security Misconfiguration | Security headers, minimal permissions, hardened defaults |
| A06 | Vulnerable Components | Dependency scanning, auto-updates |
| A07 | Auth Failures | MFA, rate limiting, secure session management |
| A08 | Data Integrity Failures | Signed updates, SRI, CI/CD pipeline security |
| A09 | Logging Failures | Structured logging, alerting on auth failures |
| A10 | SSRF | Allowlist URLs, block internal IPs, validate redirects |
Security Headers (Next.js)
const securityHeaders = [
{
key: "X-DNS-Prefetch-Control",
value: "on",
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), browsing-topics=()",
},
{
key: "X-XSS-Protection",
value: "0",
},
];
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
},
};
export default nextConfig;
Content Security Policy (CSP)
function generateNonce(): string {
return Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64");
}
export function getCSPHeader(nonce: string): string {
const directives = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`font-src 'self' https://fonts.gstatic.com`,
`connect-src 'self' https://api.example.com`,
`frame-src 'none'`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors 'none'`,
`upgrade-insecure-requests`,
];
return directives.join("; ");
}
export function middleware(request: NextRequest) {
const nonce = generateNonce();
csp = (nonce);
response = .();
response..(, csp);
response..(, nonce);
response;
}
Input Validation with Zod
import { z } from "zod";
const CreateUserSchema = z.object({
email: z.string().email().max(255).toLowerCase().trim(),
name: z.string().min(1).max(100).trim(),
password: z.string().min(8).max(128),
age: z.number().int().min(13).max(150).optional(),
});
const SearchQuerySchema = z.object({
q: z.string().max(200).trim(),
page: z.coerce.number().int().min(1).max(1000).default(1),
: z..().().().().(),
: z.([, , ]).(),
});
() {
body = request.();
result = .(body);
(!result.) {
.(
{ : , : result..() },
{ : }
);
}
validated = result.;
}
SQL Injection Prevention
const user = await db.select().from(users).where(eq(users.id, userId));
const results = await db.execute(
sql`SELECT * FROM users WHERE email = ${email} AND status = ${status}`
);
const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(userId).first();
CSRF Protection
import { randomBytes, timingSafeEqual } from "node:crypto";
function generateCSRFToken(): string {
return randomBytes(32).toString("hex");
}
function validateCSRFToken(sessionToken: string, requestToken: string): boolean {
if (!sessionToken || !requestToken) return false;
return timingSafeEqual(
Buffer.from(sessionToken),
Buffer.from(requestToken)
);
}
function validateOrigin(request: Request): boolean {
const origin = request.headers.get("origin");
const allowedOrigins = [process.env.APP_URL];
return origin !== null && allowedOrigins.includes(origin);
}
XSS Prevention
import DOMPurify from "isomorphic-dompurify";
function SafeHTML({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br", "ul", "ol", "li"],
ALLOWED_ATTR: ["href", "target", "rel"],
});
return <div>{clean}</div>;
}
function isSafeUrl(url: string): boolean {
try {
const parsed = new URL(url);
return [, , ].(parsed.);
} {
;
}
}
SSRF Prevention
import { isIP } from "node:net";
const BLOCKED_RANGES = [
/^127\./,
/^10\./,
/^172\.(1[6-9]|2\d|3[01])\./,
/^192\.168\./,
/^169\.254\./,
/^0\./,
];
function isInternalUrl(urlString: string): boolean {
try {
const url = new URL(urlString);
const hostname = url.hostname;
if (hostname === "localhost" || hostname === "metadata.google.internal") {
return true;
}
if (isIP(hostname)) {
return BLOCKED_RANGES.some((range) => range.test(hostname));
}
return false;
} catch {
return true;
}
}
(): <> {
((url)) {
();
}
(url, { : });
}
Best Practices
Headers
- Set
Strict-Transport-Security with a long max-age and preload
- Use
X-Content-Type-Options: nosniff to prevent MIME sniffing
- Set
X-Frame-Options: SAMEORIGIN or use frame-ancestors in CSP
- Use
Referrer-Policy: strict-origin-when-cross-origin
- Disable unused browser features with
Permissions-Policy
Input Handling
- Validate ALL input on the server side (never trust client validation alone)
- Use allowlists over denylists for input validation
- Limit input lengths to prevent DoS
- Sanitize HTML input with DOMPurify if rich text is required
- Validate file uploads by checking magic bytes, not just extensions
Cookie Security
- Set
HttpOnly to prevent JavaScript access
- Set
Secure to transmit only over HTTPS
- Set
SameSite=Lax minimum (or Strict for sensitive cookies)
- Scope cookies with
Path and Domain
Dependency Security
- Run
npm audit or pnpm audit in CI
- Use Dependabot or Renovate for automated updates
- Pin dependencies and review lockfile changes
- Use Subresource Integrity (SRI) for CDN scripts
Common Pitfalls
| Pitfall | Fix |
|---|
| Rendering unsanitized user HTML | Always sanitize with DOMPurify before rendering |
| String concatenation in SQL | Use parameterized queries / ORM |
Missing SameSite on cookies | Set SameSite=Lax at minimum |
Allowing javascript: URLs | Validate URL protocol against allowlist |
CSP too permissive (unsafe-inline) | Use nonces or hashes instead |
| No rate limiting on auth endpoints | Implement rate limiting |
| Logging sensitive data | Redact passwords, tokens, PII from logs |
| Open redirects | Validate redirect URLs against allowlist |
Examples
Security Audit Checklist
## Pre-Deployment Security Checklist
### Headers
- [ ] HSTS enabled with preload
- [ ] CSP configured (no unsafe-inline for scripts)
- [ ] X-Content-Type-Options: nosniff
- [ ] X-Frame-Options or CSP frame-ancestors
### Authentication
- [ ] Passwords hashed with Argon2id/bcrypt
- [ ] Rate limiting on login/signup
- [ ] Session timeout configured
- [ ] MFA available
### Data
- [ ] All queries parameterized
- [ ] Input validated server-side with Zod
- [ ] File uploads validated (type, size)
- [ ] PII encrypted at rest
### Dependencies
- [ ] npm audit clean
- [ ] No known CVEs in deps
- [ ] Lockfile committed
### Infrastructure
- [ ] TLS 1.2+ only
- [ ] CORS configured (not *)
- [ ] Error messages don't leak internals
- [ ] Logs don't contain secrets
From security-scanner
Perform OWASP Top 10 checks, dependency vulnerability audits, secret scanning, and security hardening recommendations
Security Scanner Skill
Purpose
Identify security vulnerabilities before they reach production. This skill systematically checks code against OWASP Top 10, scans for leaked secrets, audits dependencies for known CVEs, and recommends hardening measures appropriate to the application's risk profile.
Key Concepts
OWASP Top 10 (2021) Quick Reference
| # | Category | What to Check |
|---|
| A01 | Broken Access Control | Missing auth checks, IDOR, privilege escalation |
| A02 | Cryptographic Failures | Weak hashing, plaintext secrets, bad TLS config |
| A03 | Injection | SQL injection, XSS, command injection, LDAP injection |
| A04 | Insecure Design | Missing threat model, business logic flaws |
| A05 | Security Misconfiguration | Default creds, verbose errors, missing headers |
| A06 | Vulnerable Components | Outdated dependencies with known CVEs |
| A07 | Auth Failures | Weak passwords, missing MFA, session fixation |
| A08 | Data Integrity Failures | Unsigned updates, insecure deserialization, CI/CD tampering |
| A09 | Logging Failures | No audit trail, sensitive data in logs, missing alerting |
| A10 | SSRF | Unvalidated URLs, internal network access from user input |
Severity Classification
CRITICAL — Actively exploitable, data breach likely (e.g., SQL injection in auth)
HIGH — Exploitable with moderate effort (e.g., XSS in user content)
MEDIUM — Requires specific conditions to exploit (e.g., missing rate limit)
LOW — Defense-in-depth issue (e.g., missing security header)
INFO — Best practice recommendation (e.g., consider CSP nonce)
Workflow
Phase 1: Secret Scanning
Scan for hardcoded secrets, API keys, tokens, and credentials.
Patterns to detect:
API Keys and Tokens:
Strings matching (?i)(api[_-]?key|api[_-]?secret|access[_-]?token)
followed by assignment to a literal string of 16+ characters
AWS Credentials:
Access key IDs starting with AKIA followed by 16 uppercase alphanumeric chars
Secret keys: 40-character base64 strings assigned to aws_secret variables
Private Keys:
PEM headers: -----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----
Database Connection Strings:
URIs matching (mysql|postgres|mongodb|redis)://user:password@host patterns
JWT Secrets:
Variables named jwt_secret or jwt_key assigned to literal strings
Remediation:
- Remove secret from code immediately
- Rotate the compromised credential
- Add the pattern to
.gitignore and pre-commit hooks
- Use environment variables or a secret manager (Vault, AWS Secrets Manager, Doppler)
- Check git history — secrets in old commits are still exposed
git log --all -p | grep -E '(AKIA|password\s*=|secret\s*=)' | head -50
npx secretlint .
gitleaks detect --source .
Phase 2: Dependency Audit
npm audit --production
npx audit-ci --high
pip-audit
safety check
govulncheck ./...
cargo audit
Triage guidance:
- Direct dependencies with Critical/High CVEs — Update immediately
- Transitive dependencies with Critical CVEs — Check if exploit path exists
- No fix available — Evaluate alternatives, apply workaround, or accept risk with documentation
Phase 3: Code-Level Security Review
A01: Broken Access Control
app.get('/api/documents/:id', async (req, res) => {
const doc = await db.document.findUnique({ where: { id: req.params.id } });
res.json(doc);
});
app.get('/api/documents/:id', async (req, res) => {
const doc = await db.document.findUnique({
where: {
id: req.params.id,
ownerId: req.user.id,
},
});
if (!doc) return res.status(404).json({ error: 'Not found' });
res.json(doc);
});
Checklist:
A03: Injection Prevention
SQL Injection:
const query = `SELECT * FROM users WHERE email = '${userInput}'`;
const user = await db.query('SELECT * FROM users WHERE email = $1', [userInput]);
Cross-Site Scripting (XSS):
import DOMPurify from 'dompurify';
const safeHtml = DOMPurify.sanitize(userContent);
Command Injection:
import { execFile } from 'node:child_process';
execFile('convert', [validatedFilename, 'output.png'], callback);
A05: Security Headers
const securityHeaders = [
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://api.yourservice.com",
"frame-ancestors 'none'",
].join('; '),
},
];
module.exports = {
headers: async () => [{ source: '/(.*)', headers: securityHeaders }],
};
A07: Authentication Hardening
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12;
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
const sessionConfig = {
secret: process.env.SESSION_SECRET,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 3600000,
},
resave: ,
: ,
};
rateLimit ;
authLimiter = ({
: * * ,
: ,
: ,
: ,
});
app.(, authLimiter, loginHandler);
Phase 4: Infrastructure Security Check
Environment Variables:
- [ ] No secrets in code, docker images, or CI logs
- [ ] Secrets rotated on schedule
- [ ] Different secrets per environment
Network:
- [ ] Database not publicly accessible
- [ ] Internal services not exposed to internet
- [ ] Firewall rules follow least-privilege
HTTPS:
- [ ] TLS 1.2+ enforced
- [ ] HSTS header enabled with preload
- [ ] Certificate auto-renewal configured
Monitoring:
[ ]
[ ]
[ ]
Report Format
# Security Scan Report
**Date**: YYYY-MM-DD
**Scope**: [files/modules/endpoints scanned]
## Summary
| Severity | Count |
|----------|-------|
| Critical | 0 |
| High | 2 |
| Medium | 5 |
| Low | 3 |
## Findings
### [HIGH] SQL Injection in User Search (A03)
**File**: src/api/users.ts:45
**Description**: User input concatenated directly into SQL query.
**Impact**: Attacker can read/modify/delete any database record.
**Remediation**: Use parameterized queries.
**Code Fix**:
[before/after code block]
### [MEDIUM] Missing Rate Limiting on Password Reset (A07)
...
Examples
Quick Security Check for a Next.js App
- Run
npm audit --production
- Check
next.config.js for security headers
- Search codebase for dynamic code evaluation, innerHTML, and unsanitized HTML rendering
- Verify all API routes have auth middleware
- Check
.env.example does not contain real values
- Verify CORS configuration in middleware
- Check for
console.log of sensitive data in production code
Pre-Deploy Security Gate
npm audit --production --audit-level=high && \
npx secretlint . && \
echo "Security checks passed"
Common Vulnerability Patterns
| Pattern | Risk | Fix |
|---|
| String concatenation in SQL | SQL Injection | Use parameterized queries |
| innerHTML with user data | XSS | Use textContent or sanitize with DOMPurify |
| Shell commands with user input | Command Injection | Use execFile with array args, validate input |
| Hardcoded secrets in source | Credential Leak | Use environment variables or secret manager |
| Missing auth on API route | Unauthorized Access | Add authentication middleware |
| Wildcard in CORS origin | CSRF/Data theft | Whitelist specific origins |
| Default error pages in prod | Info Disclosure | Custom error pages, no stack traces |
| Unvalidated redirects | Open Redirect | Whitelist redirect destinations |
| Weak password requirements | Account Takeover | Min 12 chars, check against breach lists |
| Missing rate limiting | Brute Force/DoS | Add rate limiting on auth and sensitive endpoints |
Security Testing Patterns
Input Validation Testing
Test all user inputs with:
- Empty strings
- Very long strings (10K+ characters)
- SQL metacharacters:
' " ; -- /* */
- HTML/JS injection payloads
- Unicode: null bytes, RTL override, homoglyphs
- Path traversal:
../../../etc/passwd
- Protocol handlers:
javascript:, data:, file://
Authentication Testing
- Test login with invalid credentials (should not reveal which field is wrong)
- Test session expiry
- Test concurrent sessions
- Test password reset flow
- Test token invalidation on password change
- Test brute force protection triggers
From security-headers
HTTP security headers — HSTS, X-Frame-Options, Permissions-Policy, Referrer-Policy, CORP/COEP/COOP
HTTP Security Headers
Purpose
Provide expert guidance on configuring HTTP security headers to protect web applications against common attacks including clickjacking, MIME sniffing, protocol downgrade, information leakage, and cross-origin attacks. Covers all major security headers with platform-specific implementation for Next.js, Nginx, and Vercel.
Key Patterns
Essential Security Headers
Every production web application should set these headers.
| Header | Value | Purpose |
|---|
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | Force HTTPS for 2 years |
X-Content-Type-Options | nosniff | Prevent MIME type sniffing |
X-Frame-Options | DENY or SAMEORIGIN | Prevent clickjacking |
Referrer-Policy | strict-origin-when-cross-origin | Control referer leakage |
Permissions-Policy | camera=(), microphone=(), geolocation=() | Restrict browser APIs |
X-DNS-Prefetch-Control | off | Prevent DNS prefetch leakage |
Next.js Implementation
import type { NextConfig } from "next";
const securityHeaders = [
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "X-Frame-Options",
value: "DENY",
},
{
key: "X-DNS-Prefetch-Control",
value: "off",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), browsing-topics=()",
},
];
const nextConfig: NextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
},
poweredByHeader: false,
};
export default nextConfig;
Using middleware for dynamic headers:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const response = NextResponse.next();
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
response.headers.set(
"Strict-Transport-Security",
"max-age=63072000; includeSubDomains; preload"
);
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=()"
);
response.headers.(
,
);
response..(, nonce);
response;
}
config = {
: [
,
],
};
Nginx Configuration
# /etc/nginx/conf.d/security-headers.conf
# Include in your server block: include /etc/nginx/conf.d/security-headers.conf;
# HSTS -- only set on HTTPS server blocks
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Prevent clickjacking
add_header X-Frame-Options "DENY" always;
# Prevent MIME sniffing
add_header X-Content-Type-Options "nosniff" always;
# Referrer policy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Permissions policy
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), browsing-topics=()" always;
# Remove server version
server_tokens off;
# Remove X-Powered-By (if proxying to Node.js)
proxy_hide_header X-Powered-By;
Vercel Configuration
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Strict-Transport-Security",
"value": "max-age=63072000; includeSubDomains; preload"
},
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "Referrer-Policy",
"value": "strict-origin-when-cross-origin"
},
{
HSTS (HTTP Strict Transport Security)
Forces browsers to use HTTPS for all future requests to the domain.
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Deployment strategy -- ramp up gradually:
# Week 1: Short max-age, no includeSubDomains
Strict-Transport-Security: max-age=86400
# Week 2: Increase to 1 week
Strict-Transport-Security: max-age=604800
# Week 3: Add includeSubDomains
Strict-Transport-Security: max-age=604800; includeSubDomains
# Week 4+: Full duration and preload
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
HSTS Preload: Submit your domain to https://hstspreload.org/ to be hardcoded into browsers' HSTS list. Requirements:
- Valid HTTPS certificate
- Redirect HTTP to HTTPS
max-age >= 31536000 (1 year)
includeSubDomains directive
preload directive
Permissions-Policy
Control which browser APIs your site can use. Deny everything you do not need.
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=(), interest-cohort=()
Common directives:
| Directive | Default | Recommendation |
|---|
camera | * | () unless needed |
microphone | * | () unless needed |
geolocation | * | () or (self) |
payment | * | (self) if using Payment Request API |
browsing-topics | * | () -- opt out of Topics API |
interest-cohort | * | () -- opt out of FLoC |
fullscreen | self | (self) |
autoplay | * | (self) or () |
When embedding third-party iframes:
Permissions-Policy: camera=(self "https://meet.example.com"), microphone=(self "https://meet.example.com"), geolocation=()
Referrer-Policy
Control how much URL information is shared in the Referer header.
| Policy | Cross-Origin | Same-Origin | Recommendation |
|---|
no-referrer | Nothing | Nothing | Maximum privacy, breaks some analytics |
strict-origin | Origin only (HTTPS) | Origin only | Good for APIs |
strict-origin-when-cross-origin | Origin only (HTTPS) | Full URL | Recommended default |
same-origin | Nothing | Full URL | Good for intranets |
origin | Origin only | Origin only | Use when full URL leaks sensitive data |
Cross-Origin Isolation (CORP/COEP/COOP)
Required for SharedArrayBuffer, high-resolution timers, and performance.measureUserAgentSpecificMemory().
# Cross-Origin-Opener-Policy -- isolate browsing context
Cross-Origin-Opener-Policy: same-origin
# Cross-Origin-Embedder-Policy -- require CORP on subresources
Cross-Origin-Embedder-Policy: require-corp
# Cross-Origin-Resource-Policy -- on your static assets
Cross-Origin-Resource-Policy: same-site
Gradual rollout with reporting:
# Start with report-only mode
Cross-Origin-Opener-Policy: same-origin; report-to="coop-report"
Cross-Origin-Embedder-Policy: require-corp; report-to="coep-report"
# Reporting endpoint
Reporting-Endpoints: coop-report="https://myapp.com/api/reports/coop", coep-report="https://myapp.com/api/reports/coep"
When COEP breaks third-party resources:
<img src="https://cdn.example.com/image.jpg" crossorigin="anonymous" />
<iframe src="https://widget.example.com" credentialless></iframe>
Testing Security Headers
curl -I https://myapp.com | grep -iE "(strict|x-frame|x-content|referrer|permissions|cross-origin)"
Best Practices
- Set headers on all responses -- use
always directive in Nginx; apply to /(.*) in Next.js/Vercel.
- Ramp up HSTS gradually -- start with short
max-age and increase once confirmed working.
- Deny by default in Permissions-Policy -- only allow APIs your application actually uses.
- Use
strict-origin-when-cross-origin as the default Referrer-Policy -- it balances privacy with analytics needs.
- Remove server identification headers --
X-Powered-By, Server version strings leak technology stack.
- Test after deployment -- use securityheaders.com and Mozilla Observatory to verify headers are applied correctly.
- Document header decisions -- record why each header value was chosen for future maintainers.
Common Pitfalls
| Pitfall | Problem | Fix |
|---|
HSTS with preload before testing | Cannot easily undo preload submission | Test without preload first; submit only when fully confident |
X-Frame-Options: DENY breaking own embeds | Legitimate iframes within your app blocked | Use SAMEORIGIN if you embed your own pages; use CSP frame-ancestors for more control |
Forgetting always in Nginx | Headers not sent on error responses (4xx, 5xx) | Add always to every add_header directive |
| COEP breaking third-party images | Images without CORP header fail to load | Add crossorigin="anonymous" to img tags or use credentialless |
| Permissions-Policy blocking wanted features | Camera/mic denied when app needs them | Explicitly allow with camera=(self) for your origin |
| Headers set in CDN and origin | Duplicate headers with conflicting values | Set security headers at one layer only (preferably CDN/edge) |
From encryption
Encryption patterns, key management, hashing, digital signatures, TLS, and secure data storage
Encryption Specialist
Purpose
Implement correct cryptographic patterns for data protection including symmetric encryption (AES-GCM), asymmetric encryption (RSA, ECDSA), hashing (SHA-256, Argon2), HMAC, digital signatures, key management, and TLS configuration.
Key Patterns
Algorithm Selection Guide
| Use Case | Algorithm | Notes |
|---|
| Password hashing | Argon2id | Memory-hard, best for passwords |
| Password hashing (alt) | bcrypt (cost 12+) | Well-tested, widely available |
| Data integrity | SHA-256 | Fast, collision-resistant |
| Message auth | HMAC-SHA256 | Verify integrity + authenticity |
| Symmetric encryption | AES-256-GCM | Authenticated encryption |
| Asymmetric encryption | RSA-OAEP (2048+) | Key exchange, small data |
| Digital signatures | Ed25519 or ECDSA P-256 | Fast, compact signatures |
| Key derivation | HKDF or PBKDF2 | Derive keys from passwords/secrets |
| Random tokens | crypto.randomBytes(32) | API keys, session tokens |
AES-256-GCM Encryption (Web Crypto API)
const ALGORITHM = "AES-GCM";
const KEY_LENGTH = 256;
const IV_LENGTH = 12;
const TAG_LENGTH = 128;
export async function generateEncryptionKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey(
{ name: ALGORITHM, length: KEY_LENGTH },
true,
["encrypt", "decrypt"]
);
}
export async function importKey(rawKey: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey(
"raw",
rawKey,
{ name: ALGORITHM, length: KEY_LENGTH },
false,
[, ]
);
}
(): <> {
iv = crypto.( ());
encoded = ().(plaintext);
ciphertext = crypto..(
{ : , iv, : },
key,
encoded
);
combined = (iv. + ciphertext.);
combined.(iv);
combined.( (ciphertext), iv.);
(.(...combined));
}
(): <> {
combined = .((encryptedBase64), c.());
iv = combined.(, );
ciphertext = combined.();
decrypted = crypto..(
{ : , iv, : },
key,
ciphertext
);
().(decrypted);
}
HMAC for Webhook Verification
import { createHmac, timingSafeEqual } from "node:crypto";
export function signPayload(payload: string, secret: string): string {
return createHmac("sha256", secret).update(payload).digest("hex");
}
export function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const expected = signPayload(payload, secret);
const sigBuffer = Buffer.from(signature, "hex");
const expectedBuffer = Buffer.from(expected, "hex");
if (sigBuffer.length !== expectedBuffer.length) return false;
return timingSafeEqual(sigBuffer, expectedBuffer);
}
export async function () {
body = request.();
signature = request..();
(!signature || !(body, signature, process..!)) {
(, { : });
}
event = .(body);
}
Password Hashing (Argon2id)
import { hash, verify } from "@node-rs/argon2";
const ARGON2_OPTIONS = {
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
outputLen: 32,
};
export async function hashPassword(password: string): Promise<string> {
return hash(password, ARGON2_OPTIONS);
}
export async function verifyPassword(storedHash: string, password: string): Promise<boolean> {
try {
return await verify(storedHash, password);
} catch {
return false;
}
}
Secure Token Generation
import { randomBytes, randomUUID } from "node:crypto";
export function generateApiKey(): string {
return randomBytes(32).toString("base64url");
}
export function generateSessionId(): string {
return randomUUID();
}
export function generateVerificationCode(): string {
const buffer = randomBytes(4);
const num = buffer.readUInt32BE(0) % 1_000_000;
return num.toString().padStart(6, "0");
}
export function generateUrlToken(): string {
return randomBytes(48).toString("base64url");
}
Field-Level Encryption (Database)
const ENCRYPTION_KEY = Buffer.from(process.env.FIELD_ENCRYPTION_KEY!, "base64");
export async function encryptField(value: string): Promise<string> {
const key = await importKey(ENCRYPTION_KEY);
return encrypt(value, key);
}
export async function decryptField(encrypted: string): Promise<string> {
const key = await importKey(ENCRYPTION_KEY);
return decrypt(encrypted, key);
}
async function createUser(data: { email: string; ssn: string }) {
return db.insert(users).values({
email: data.email,
ssnEncrypted: (data.),
});
}
Best Practices
General Rules
- Never implement your own cryptographic algorithms
- Use well-audited libraries (Web Crypto API, libsodium, @node-rs/argon2)
- Always use authenticated encryption (AES-GCM, ChaCha20-Poly1305)
- Use constant-time comparison for secrets (
timingSafeEqual)
- Generate unique IVs/nonces for every encryption operation
- Never reuse an IV with the same key in GCM mode
Key Management
- Store encryption keys in environment variables or secret managers (AWS KMS, Vault)
- Rotate keys periodically; support multiple active key versions
- Use key derivation (HKDF) to derive per-purpose keys from a master key
- Never log, commit, or embed keys in source code
- Use separate keys for different purposes (encryption, signing, hashing)
Password Storage
- Use Argon2id (or bcrypt as fallback) — never SHA-256/MD5 for passwords
- Use per-user random salts (Argon2 handles this automatically)
- Tune cost parameters so hashing takes 100-500ms
- Rehash on login when upgrading algorithms
Data at Rest
- Encrypt PII and sensitive fields at the application level
- Use database-level encryption (TDE) as defense in depth
- Encrypt backups with a separate key
Common Pitfalls
| Pitfall | Fix |
|---|
| Using MD5/SHA for passwords | Use Argon2id or bcrypt |
| Reusing IVs in AES-GCM | Generate a fresh random IV every time |
| String comparison for secrets | Use timingSafeEqual |
| ECB mode for AES | Use GCM or CBC with HMAC |
| Hardcoded encryption keys | Use env vars or key management service |
Math.random() for tokens | Use crypto.randomBytes() or crypto.getRandomValues() |
| Logging decrypted values | Never log sensitive plaintext |
Examples
Key Rotation Pattern
interface KeyVersion {
version: number;
key: CryptoKey;
active: boolean;
}
class KeyManager {
private keys: Map<number, KeyVersion> = new Map();
private activeVersion: number = 0;
async encrypt(plaintext: string): Promise<{ version: number; data: string }> {
const active = this.keys.get(this.activeVersion)!;
const data = await encrypt(plaintext, active.key);
return { version: active.version, data };
}
async decrypt(version: number, encryptedData: string): Promise<string> {
const keyVersion = this..(version);
(!keyVersion) ();
(encryptedData, keyVersion.);
}
}
Ed25519 Digital Signatures
async function generateSigningKeys() {
return crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
}
async function sign(data: string, privateKey: CryptoKey): Promise<string> {
const encoded = new TextEncoder().encode(data);
const signature = await crypto.subtle.sign("Ed25519", privateKey, encoded);
return btoa(String.fromCharCode(...new Uint8Array(signature)));
}
async function verify(data: string, signature: string, publicKey: CryptoKey): Promise<boolean> {
const encoded = new TextEncoder().encode(data);
const sigBytes = .((signature), c.());
crypto..(, publicKey, sigBytes, encoded);
}
From csp
Content Security Policy — CSP headers, nonce-based script loading, report-uri/report-to, XSS prevention, and framework-specific configurations
Content Security Policy (CSP) Skill
Purpose
Content Security Policy is the most effective defense against Cross-Site Scripting (XSS). CSP tells the browser which sources of content (scripts, styles, images, etc.) are allowed. An attacker who injects a <script> tag is blocked because the script's origin is not in the policy. This skill covers header configuration, nonce-based scripts, reporting, and framework integration.
Key Concepts
CSP Directive Reference
| Directive | Controls | Example |
|---|
default-src | Fallback for all directives | 'self' |
script-src | JavaScript sources | 'self' 'nonce-abc123' |
style-src | CSS sources | 'self' 'unsafe-inline' |
img-src | Image sources | 'self' data: https://cdn.example.com |
font-src | Font sources | 'self' https://fonts.gstatic.com |
connect-src | XHR, fetch, WebSocket | 'self' https://api.example.com |
media-src | Audio/video sources | 'self' |
frame-src | iframe sources | https://www.youtube.com |
frame-ancestors | Who can iframe this page | 'none' (prevents clickjacking) |
object-src | Flash, Java applets | 'none' (always) |
base-uri | <base> tag restriction | 'self' |
form-action | Form submission targets | 'self' |
report-uri | Where to send violation reports | /api/csp-report |
report-to | Reporting API v2 | csp-endpoint |
upgrade-insecure-requests | Upgrade HTTP to HTTPS | (no value) |
Source Values
| Value | Meaning |
|---|
'self' | Same origin only |
'none' | Block everything |
'unsafe-inline' | Allow inline scripts/styles (defeats CSP for XSS) |
'unsafe-eval' | Allow eval() (dangerous) |
'nonce-{base64}' | Allow specific inline script/style with matching nonce |
'strict-dynamic' | Trust scripts loaded by already-trusted scripts |
https: | Any HTTPS source |
data: | Data URIs (images, fonts) |
blob: | Blob URIs |
https://cdn.example.com | Specific origin |
Security Levels
Level 0: No CSP (default — no protection)
Level 1: Report-only mode (monitor without blocking)
Level 2: Basic CSP with allowlists (blocks obvious attacks)
Level 3: Strict nonce-based CSP (blocks most XSS, including injected scripts)
Level 4: Strict + no unsafe-inline styles (maximum protection, hardest to implement)
Workflow
Step 1: Start with Report-Only Mode
Never deploy CSP in enforcement mode first. Start by monitoring:
const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: https:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
object-src 'none';
base-uri 'self';
form-action 'self';
report-uri /api/csp-report;
`.replace(/\n/g, ' ').trim();
const nextConfig = {
headers: async () => [
{
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy-Report-Only',
value: cspHeader,
},
],
},
],
};
Step 2: CSP Violation Report Endpoint
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const report = await request.json();
const violation = report['csp-report'] || report;
console.warn('CSP Violation:', {
blockedUri: violation['blocked-uri'],
violatedDirective: violation['violated-directive'],
documentUri: violation['document-uri'],
sourceFile: violation['source-file'],
lineNumber: violation['line-number'],
originalPolicy: violation['original-policy'],
});
return NextResponse.json({ received: true }, { status: 204 });
} catch {
return NextResponse.json({ error: 'Invalid report' }, { : });
}
}
Step 3: Nonce-Based CSP (Strict Mode)
Nonces are the recommended approach. Each response generates a unique nonce that must match inline scripts:
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}'`,
`img-src 'self' data: blob: https:`,
`font-src 'self' https://fonts.gstatic.com`,
`connect-src 'self' https://api.example.com wss://api.example.com`,
`frame-ancestors 'none'`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`upgrade-insecure-requests`,
`report-uri /api/csp-report`,
].join('; ');
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', csp);
response..(, nonce);
response..(, );
response..(, );
response..(, );
response..(, );
response;
}
config = {
: [
{ : },
],
};
Step 4: Use Nonce in Next.js Layout
import { headers } from 'next/headers';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const headersList = await headers();
const nonce = headersList.get('X-Nonce') ?? '';
return (
<html lang="en">
<head>
{/* Third-party scripts must include the nonce */}
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `
// Theme detection (blocking — must run before paint)
(function() {
const theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('');
}
})();
`,
}}
/>
);
}
Step 5: Express.js CSP with Helmet
import helmet from 'helmet';
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
(req, res) => `'nonce-${res.locals.nonce}'`,
],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
connectSrc: ["'self'", "https://api.example.com"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
upgradeInsecureRequests: [],
reportUri: ["/api/csp-report"],
},
},
})
);
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).();
();
});
Step 6: Handling Common Third-Party Scripts
const thirdPartyScripts = {
googleAnalytics: 'https://www.googletagmanager.com https://www.google-analytics.com',
googleFonts: 'https://fonts.googleapis.com https://fonts.gstatic.com',
stripe: 'https://js.stripe.com https://api.stripe.com',
cloudflare: 'https://challenges.cloudflare.com',
youtube: 'https://www.youtube.com https://www.youtube-nocookie.com',
vercelAnalytics: 'https://va.vercel-scripts.com',
sentry: 'https://*.ingest.sentry.io',
};
Step 7: Gradual Migration Path
Week 1: Add CSP in Report-Only mode with permissive policy
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; ...
Week 2: Review violation reports, add legitimate sources
- Add CDN origins
- Add API origins to connect-src
- Identify inline scripts that need nonces
Week 3: Implement nonce generation, add nonces to inline scripts
script-src 'self' 'nonce-xxx' 'strict-dynamic'
Week 4: Remove 'unsafe-inline' and 'unsafe-eval' from script-src
(strict-dynamic with nonce supersedes unsafe-inline)
Week 5: Switch from Report-Only to enforcing
Content-Security-Policy: ... (same policy, now blocking)
Week 6: Monitor reports, fix any remaining violations
Common Pitfalls
- Deploying CSP in enforcement mode without testing — Always start with
Content-Security-Policy-Report-Only. Enforcement mode will break your site if the policy is wrong.
- Using
unsafe-inline for scripts — This defeats CSP's XSS protection entirely. Use nonces instead. With 'strict-dynamic' and a nonce, you get strong protection.
- Forgetting
connect-src for API calls — fetch() and WebSocket connections are controlled by connect-src, not script-src. Forgetting this breaks all API calls.
- Not allowing
data: for images — Many applications use data URIs for thumbnails, avatars, or placeholders. Add data: to img-src if needed.
- Nonce reuse — Every HTTP response must have a unique nonce. Reusing nonces across requests defeats the purpose. Generate per-request in middleware.
- CSP breaking CSS-in-JS — Libraries like styled-components inject
<style> tags at runtime. Either add nonces to the style injection or allow 'unsafe-inline' for style-src only (style injection is lower risk than script injection).
- Forgetting
frame-ancestors 'none' — Without this, your site can be embedded in an iframe for clickjacking attacks. Always set frame-ancestors.
Complete Security Headers Set
const securityHeaders = {
'Content-Security-Policy': csp,
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '0',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload',
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Resource-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
};
Best Practices
- Start with Report-Only: Monitor before enforcing
- Use nonces, not allowlists: Nonces are more secure than domain allowlists (which can be bypassed via JSONP endpoints)
strict-dynamic is your friend: Trusts scripts loaded by trusted scripts, reducing allowlist maintenance
object-src 'none' always: Flash/Java applets are attack vectors with no modern use
- Monitor violations continuously: CSP reports reveal attack attempts and misconfigurations
- Test with browser DevTools: Console shows blocked resources with the violated directive
From cors
Configure Cross-Origin Resource Sharing (CORS) policies — Access-Control headers, preflight handling, credentials, wildcard risks, and dev proxy setups for secure cross-origin communication
CORS Configuration Skill
Purpose
CORS is the browser's same-origin policy enforcement mechanism. Misconfigured CORS either blocks legitimate requests (breaking your app) or opens your API to unauthorized origins (security hole). This skill configures CORS correctly for every environment — development, staging, and production — with proper preflight handling, credential support, and security hardening.
Key Concepts
How CORS Works
Browser sends request from origin A to server at origin B
1. SIMPLE REQUEST (GET/POST with standard headers):
Browser → Server: Request + Origin header
Server → Browser: Response + Access-Control-Allow-Origin
Browser: If origin matches → allow, else → block JS access
2. PREFLIGHT REQUEST (PUT/DELETE, custom headers, JSON content-type):
Browser → Server: OPTIONS + Origin + Access-Control-Request-Method/Headers
Server → Browser: Access-Control-Allow-* headers
Browser: If allowed → send actual request, else → block
CORS Header Reference
| Header | Direction | Purpose |
|---|
Access-Control-Allow-Origin | Response | Which origin(s) can read the response |
Access-Control-Allow-Methods | Response (preflight) | Allowed HTTP methods |
Access-Control-Allow-Headers | Response (preflight) | Allowed request headers |
Access-Control-Allow-Credentials | Response | Whether cookies/auth are allowed |
Access-Control-Expose-Headers | Response | Headers JS can read beyond defaults |
Access-Control-Max-Age | Response (preflight) | How long to cache preflight result (seconds) |
Origin | Request | The requesting origin (set by browser) |
The Credentials Trap
Access-Control-Allow-Origin: * ← OK without credentials
Access-Control-Allow-Credentials: true ← Requires SPECIFIC origin, NOT wildcard
❌ INVALID: Allow-Origin: * + Allow-Credentials: true
✅ VALID: Allow-Origin: https://app.example.com + Allow-Credentials: true
Implementation
Express.js with cors Package
import cors from 'cors';
import express from 'express';
const app = express();
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
];
app.use(cors({
origin: (origin, callback) => {
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
exposedHeaders: ['X-Total-Count', 'X-Request-ID'],
credentials: true,
maxAge: ,
}));
Express.js Manual Implementation (No Dependencies)
import { Request, Response, NextFunction } from 'express';
function corsMiddleware(req: Request, res: Response, next: NextFunction) {
const origin = req.headers.origin;
const allowedOrigins = new Set([
'https://app.example.com',
'https://admin.example.com',
]);
if (origin && allowedOrigins.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '86400');
return res.().();
}
();
}
Next.js API Routes
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Origin', value: 'https://app.example.com' },
{ key: 'Access-Control-Allow-Methods', value: 'GET, POST, PUT, DELETE, OPTIONS' },
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
],
},
];
},
};
export default nextConfig;
import { NextRequest, NextResponse } from 'next/server';
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
]);
export function middleware(request: NextRequest) {
const origin = request.headers.get('origin') ?? '';
const isPreflight = request.method === 'OPTIONS';
if (!ALLOWED_ORIGINS.has(origin)) {
return isPreflight
? new NextResponse(null, { status: 403 })
: NextResponse.next();
}
const response = isPreflight
? new NextResponse(null, { status: 204 })
: NextResponse.next();
response.headers.set('Access-Control-Allow-Origin', origin);
response.headers.set(, );
response..(, );
(isPreflight) {
response..(, );
response..(, );
response..(, );
}
response;
}
config = { : };
Go (net/http)
func corsMiddleware(next http.Handler) http.Handler {
allowedOrigins := map[string]bool{
"https://app.example.com": true,
"https://admin.example.com": true,
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if allowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Vary", "Origin")
}
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "86400")
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
Dev Proxy (Avoiding CORS in Development)
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: '/api/external/:path*',
destination: 'http://localhost:8080/:path*',
},
];
},
};
Nginx Reverse Proxy
server {
listen 443 ssl;
server_name api.example.com;
location / {
# Dynamic origin validation
set $cors_origin "";
if ($http_origin ~* "^https://(app|admin)\.example\.com$") {
set $cors_origin $http_origin;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Vary' 'Origin' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
add_header 'Access-Control-Max-Age' 86400;
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://backend;
}
}
Best Practices
- Always set
Vary: Origin when the response differs by origin. Without it, CDNs may serve a cached response with the wrong Allow-Origin header to a different origin.
- Never use
Access-Control-Allow-Origin: * in production with credentials. The browser will reject it. Use an explicit origin allowlist.
- Cache preflight responses with
Access-Control-Max-Age to reduce OPTIONS requests. 86400 (24h) is a safe default; browsers cap at varying maximums.
- Keep
allowedHeaders minimal. Only list headers your API actually reads. Overly permissive headers expand the attack surface.
- Validate origins server-side, not just via CORS. CORS is a browser feature — non-browser clients bypass it entirely.
- Use dev proxies instead of permissive CORS in development. This avoids the need for environment-conditional CORS configs.
Common Pitfalls
| Pitfall | Symptom | Fix |
|---|
| Wildcard with credentials | Browser error: "Cannot use wildcard with credentials" | Replace * with explicit origin from allowlist |
Missing Vary: Origin | CDN serves wrong origin's CORS header | Add Vary: Origin to every CORS response |
| Forgetting OPTIONS handler | Preflight returns 404 or 405 | Ensure OPTIONS routes exist and return 204 |
| Regex origin matching | ReDoS vulnerability or overly broad matches | Use exact Set lookup, not regex on untrusted input |
| CORS on server-to-server calls | Unnecessary headers on internal traffic | Only apply CORS middleware to public-facing routes |
| Double CORS headers | Access-Control-Allow-Origin appears twice | Check that only one layer (app OR proxy) sets CORS, not both |
Content-Type: application/json triggers preflight | Unexpected OPTIONS requests on POST | This is correct behavior — ensure preflight handler exists |
Dev * leaking to production | Open CORS in production | Use environment-based origin allowlists, never hardcode * |