Sets up the full suite of browser security headers through Next.js middleware: CSP with dynamic Clerk/Convex/Stripe origins, X-Frame-Options to block clickjacking, X-Content-Type-Options to prevent MIME sniffing attacks, and HSTS for production HTTPS enforcement. The approach here is environment-aware CSP that pulls domains from env vars rather than hardcoding them, which is cleaner for multi-environment setups. It includes unsafe-inline and unsafe-eval for Next.js compatibility, which is a practical tradeoff most apps make. Use this when you need defense-in-depth headers configured correctly from the start, especially if you're integrating third-party auth or payments and want to whitelist only what's necessary.
npx -y skills add harperaa/secure-claude-skills --skill security-headers --agent claude-codeInstalls into .claude/skills of the current project.
Think of security headers as the walls and moat around your castle. Even if attackers get past the gate (your authentication), the walls (headers) prevent them from moving freely or exfiltrating data.
Modern browsers have built-in security features, but they're opt-in. Without the right headers, browsers allow:
Security headers tell the browser: "Enable all your security features for my site."
According to a 2023 security audit of top 10,000 websites by Scott Helme, only 2.8% properly implement all recommended security headers. The remaining 97.2% are vulnerable to attacks that headers would prevent.
Magecart Attacks (2018-2020): Hundreds of e-commerce sites were compromised by injected payment-stealing JavaScript. Content-Security-Policy headers would have prevented these scripts from executing. Sites without CSP lost millions in fraudulent transactions.
All headers are applied automatically via middleware.ts on every request. You don't need to manually set them—they're already protecting you.
What it does: Controls what resources (scripts, styles, images) can load and from where.
Our configuration:
// Dynamic CSP based on environment variables
const clerkDomain = process.env.NEXT_PUBLIC_CLERK_FRONTEND_API_URL
? new URL(process.env.NEXT_PUBLIC_CLERK_FRONTEND_API_URL).origin
: '';
const convexDomain = process.env.NEXT_PUBLIC_CONVEX_URL
? new URL(process.env.NEXT_PUBLIC_CONVEX_URL).origin
: '';
const csp = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline' 'unsafe-eval' ${clerkDomain} https://js.stripe.com`,
`style-src 'self' 'unsafe-inline' ${clerkDomain}`,
`connect-src 'self' ${clerkDomain} ${convexDomain} https://api.stripe.com`,
`frame-src 'self' ${clerkDomain} https://js.stripe.com https://hooks.stripe.com`,
"img-src 'self' data: https: blob:",
"font-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'"
].join('; ');
response.headers.set('Content-Security-Policy', csp);
What This Means:
Why Dynamic Configuration:
const clerkDomain = process.env.NEXT_PUBLIC_CLERK_FRONTEND_API_URL
? new URL(process.env.NEXT_PUBLIC_CLERK_FRONTEND_API_URL).origin
: ''
We don't hardcode Clerk's domain. It comes from environment variables. This means:
Trade-off: We allow unsafe-inline and unsafe-eval for scripts. This is required for Next.js and Clerk to function. We mitigate this risk through input sanitization and validation.
Prevent Data Exfiltration:
The connect-src directive prevents malicious scripts from sending data to unauthorized domains. Even if XSS bypasses sanitization, the browser blocks unauthorized network requests.
What it prevents: Clickjacking attacks where your site is embedded in invisible iframe on attacker's site.
Attack scenario: Attacker embeds your "Delete Account" button in an invisible iframe overlay on a game site. Users think they're clicking "Play Game" but actually click "Delete Account."
Our protection:
response.headers.set('X-Frame-Options', 'DENY');
DENY means browsers refuse to embed our site in ANY iframe, even our own. Maximum security.
Alternative values:
DENY - No iframes at all (most secure, what we use)SAMEORIGIN - Only our own site can iframe usALLOW-FROM uri - Deprecated, don't useWhat it prevents: MIME confusion attacks where browsers execute images as JavaScript.
Attack scenario: Attacker uploads file "avatar.jpg" that contains JavaScript. Old browsers try to be "helpful" and "sniff" the file type, detecting JavaScript, and execute it.
Our protection:
response.headers.set('X-Content-Type-Options', 'nosniff');
nosniff tells browsers to strictly follow Content-Type headers, never guess.
Why This Matters: Without this header, an attacker could:
<script>evil()</script><img src="/uploads/image.jpg">With nosniff, browser sees Content-Type: image/jpeg and refuses to execute as script.
What it prevents: SSL stripping attacks where man-in-the-middle downgrades HTTPS to HTTP.
Attack scenario: User types "yourapp.com" (no https://). Browser initially requests HTTP. Attacker intercepts, serves fake HTTP version, steals credentials.
Our protection:
if (process.env.NODE_ENV === 'production') {
response.headers.set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
}
Configuration breakdown:
max-age=31536000 - 1 year durationincludeSubDomains - Applies to all subdomainsWhy production only: In development, you're on localhost (HTTP). HSTS would break local development. Our middleware detects environment and enables HSTS only in production.
Important: Once HSTS is set for a domain, browsers remember it. If you need to remove it, you must:
max-age=0What it prevents: Search engines indexing private content.
Why it matters:
You don't want /dashboard/payment-details showing up in Google search results.
Our implementation:
if (req.nextUrl.pathname.startsWith('/dashboard')) {
response.headers.set('X-Robots-Tag', 'noindex, nofollow');
}
Applied to: /dashboard/* routes only (public pages should be indexed)
What this tells search engines:
noindex - Don't add this page to search resultsnofollow - Don't follow links on this pageimport { clerkMiddleware } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';
export default clerkMiddleware((auth, req) => {
const response = NextResponse.next();
// Get dynamic domains from environment
const clerkDomain = process.env.NEXT_PUBLIC_CLERK_FRONTEND_API_URL
? new URL(process.env.NEXT_PUBLIC_CLERK_FRONTEND_API_URL).origin
: '';
const convexDomain = process.env.NEXT_PUBLIC_CONVEX_URL
? new URL(process.env.NEXT_PUBLIC_CONVEX_URL).origin
: '';
// Build CSP dynamically
const csp = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline' 'unsafe-eval' ${clerkDomain} https://js.stripe.com`,
`style-src 'self' 'unsafe-inline' ${clerkDomain}`,
`connect-src 'self' ${clerkDomain} ${convexDomain} https://api.stripe.com`,
`frame-src 'self' ${clerkDomain} https://js.stripe.com https://hooks.stripe.com`,
"img-src 'self' data: https: blob:",
"font-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'"
].join('; ');
// Apply security headers
response.headers.set('Content-Security-Policy', csp);
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
// HSTS only in production
if (process.env.NODE_ENV === 'production') {
response.headers.set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
}
// Prevent indexing of protected routes
if (req.nextUrl.pathname.startsWith('/dashboard')) {
response.headers.set('X-Robots-Tag', 'noindex, nofollow');
}
return response;
});
export const config = {
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
};
curl -I http://localhost:3000
# Expected headers:
# X-Frame-Options: DENY
# X-Content-Type-Options: nosniff
# Content-Security-Policy: default-src 'self'; ...
# In production
curl -I https://yourapp.com
# Should include:
# Strict-Transport-Security: max-age=31536000; includeSubDomains
curl -I http://localhost:3000/dashboard
# Should include:
# X-Robots-Tag: noindex, nofollow
Use these online tools to test your deployed site:
When adding new third-party services:
// middleware.ts
// Add Google Analytics domain to CSP
const csp = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline' 'unsafe-eval' ${clerkDomain} https://js.stripe.com https://www.googletagmanager.com https://www.google-analytics.com`,
`style-src 'self' 'unsafe-inline' ${clerkDomain}`,
`connect-src 'self' ${clerkDomain} ${convexDomain} https://api.stripe.com https://www.google-analytics.com https://analytics.google.com`,
// ... rest of CSP
].join('; ');
const cdnDomain = process.env.NEXT_PUBLIC_CDN_URL
? new URL(process.env.NEXT_PUBLIC_CDN_URL).origin
: '';
const csp = [
"default-src 'self'",
`script-src 'self' ${cdnDomain}`,
`style-src 'self' ${cdnDomain}`,
`img-src 'self' ${cdnDomain} https:`,
// ... rest of CSP
].join('; ');
Symptom: Scripts in <script> tags don't execute
Solution: Next.js requires unsafe-inline and unsafe-eval. Already configured in our CSP.
Better alternative: Use nonces (requires SSR changes):
const nonce = crypto.randomBytes(16).toString('base64');
script-src 'self' 'nonce-${nonce}'
Symptom: User avatars from Gravatar/etc don't show
Current solution: img-src 'self' data: https: blob: allows all HTTPS images
Stricter alternative:
`img-src 'self' data: https://gravatar.com https://images.yourapp.com`
Symptom: Convex real-time updates don't work
Solution: Ensure connect-src includes Convex domain:
`connect-src 'self' ${convexDomain}`
✅ Clickjacking (X-Frame-Options) ✅ XSS amplification (CSP) ✅ MIME confusion (X-Content-Type-Options) ✅ SSL stripping (HSTS) ✅ Search engine exposure of private data (X-Robots-Tag) ✅ Data exfiltration (CSP connect-src) ✅ Unauthorized resource loading (CSP default-src)
❌ DON'T hardcode domains in CSP - Use environment variables
❌ DON'T enable HSTS in development - Breaks localhost
❌ DON'T use X-Frame-Options: ALLOW-FROM - Deprecated
❌ DON'T forget to test headers after deployment
❌ DON'T set overly permissive CSP (like * wildcards)
✅ DO use dynamic CSP with environment variables ✅ DO test headers with online tools after deployment ✅ DO update CSP when adding new third-party services ✅ DO keep HSTS production-only ✅ DO protect dashboard routes from indexing
input-validation skillsecurity-testing skillmiddleware.ts:1 for implementationhoodini/ai-agents-skills
addyosmani/agent-skills
giuseppe-trisciuoglio/developer-kit
agamm/claude-code-owasp