L37 · Security headers and CORS done right
Module 5: Web Application Security · ~20 min · Lesson 37 of 71
🎯 What you'll learn
- Which response headers are worth setting, for a browser app and for a JSON API, and what each one tells the browser to do
- What the same-origin policy blocks, and what CORS actually relaxes (it isn't a firewall)
- How a CORS preflight works, header by header
- The CORS mistakes that let any website read your customers' data, and how to spot them in a Kong
corsplugin config
🔥 Why this matters
In 2016, James Kettle of PortSwigger presented research called "Exploiting CORS misconfigurations for
Bitcoins and bounties". He found sites, including Bitcoin exchanges, that copied whatever Origin
a request sent into Access-Control-Allow-Origin and also allowed credentials. Any web page a
logged-in user visited could read their account data. Now picture a Friday fix on a team that runs
Kong: an advisor portal gets CORS errors, so someone sets the cors plugin to origins: ["*"] with
credentials: true. The errors go away. It's the same bug.
1. Response headers are instructions to the browser
Most security headers don't change what your server does. They change what the browser does with your response: which protocol to use next time, whether to guess a content type, whether another site may frame the page or read the data.
api.example-bank.com browser
│ HTTP/1.1 200 OK │
│ Strict-Transport-Security ───────────▶ │ HTTPS only, for
│ │ the next year
│ X-Content-Type-Options: nosniff ─────▶ │ trust the declared
│ │ type, never guess
│ Content-Security-Policy ─────────────▶ │ listed JS only,
│ │ no framing
│ Access-Control-Allow-Origin ─────────▶ │ which other sites
│ │ may READ this
That has a consequence people miss: curl, Java's HttpClient, Python requests and Airflow
operators ignore all of these. They protect people using browsers: a customer portal, Swagger UI,
the Airflow web UI, Kong Manager. For a JSON API they're cheap defence in depth, because sooner or
later someone opens it in a browser.
2. The short list
| Header | Typical value | What it stops |
|---|---|---|
Strict-Transport-Security |
max-age=31536000; includeSubDomains |
Downgrade to plain HTTP on later visits |
Content-Security-Policy |
Page: nonce-based (L32). API: default-src 'none'; frame-ancestors 'none' |
Injected scripts; framing |
X-Content-Type-Options |
nosniff |
The browser "guessing" a JSON or uploaded file is HTML or script |
X-Frame-Options |
DENY |
Clickjacking, for old browsers (frame-ancestors supersedes it) |
Referrer-Policy |
strict-origin-when-cross-origin or no-referrer |
Full URLs with IDs or codes leaking in Referer |
Cache-Control |
no-store |
Account data kept in browser or shared caches |
Permissions-Policy |
camera=(), geolocation=() |
Embedded content using powerful browser features |
Two to remove rather than add. X-XSS-Protection switched on an old XSS filter that browsers
have since removed (Chrome dropped it in 2019), and the filter itself could be abused; send 0 or
nothing. Server and X-Powered-By only advertise versions (L09).
In clickjacking, an attacker's page loads your "Confirm transfer" screen in an invisible
<iframe> under a fake button. frame-ancestors 'none' forbids framing your page at all.
HTTP Strict Transport Security (HSTS) needs a picture. Without it, a user who types
example-bank.com makes their first request over plain HTTP, and an attacker on café Wi-Fi can
answer it. This is SSL stripping: the attacker talks HTTPS to you and plain HTTP to the victim.
WITHOUT HSTS WITH HSTS (seen before)
user types example-bank.com user types example-bank.com
│ 1. http:// request │ 1. browser rewrites to
▼ │ https:// itself (Chrome
café attacker ◀─ reads cookies, │ shows "307 Internal
│ rewrites links │ Redirect")
│ 2. https:// to the real site ▼
▼ real site, TLS from byte one
real site (sees nothing wrong) attacker sees only TLS
The browser only accepts the header over HTTPS, and only after a first visit. The preload list (first deep dive) closes that gap.
3. Same-origin policy: what it blocks, what CORS relaxes
An origin is the triple scheme + host + port. https://portal.example-bank.com and
https://api.example-bank.com are different origins, and so are http:// and https:// versions
of the same host.
The same-origin policy (SOP) is the browser rule that a page from one origin can send many
requests to another origin, but can't read the responses. That's why a random site can't fetch
/v1/accounts/me from your bank in the background and read your balance.
Cross-Origin Resource Sharing (CORS) is how a server opts out of that rule for named origins:
"pages from https://portal.example-bank.com may read my responses." CORS never adds protection;
it removes some, on purpose. So:
- CORS is enforced by the browser. curl and server-to-server calls never check it.
- It isn't authentication. A request sent without a preflight (a simple request, L33) still reaches your server whatever your policy says; the browser only hides the response.
- The danger is in what the browser attaches automatically: cookies, cached Basic credentials, client certificates, and network position. A page that can read your intranet API through an employee's browser has effectively joined your VPN.
For anything else (JSON bodies, an Authorization header, PUT), the browser first asks with a
preflight:
portal.example-bank.com api.example-bank.com (Kong)
(page in the browser)
│ 1. OPTIONS /v1/accounts/me │
│ Origin: https://portal.example-bank.com │
│ Access-Control-Request-Method: GET │
│ Access-Control-Request-Headers: │
│ authorization │
│───────────────────────────────────────────▶│
│ 2. 200 OK │
│ Access-Control-Allow-Origin: │
│ https://portal.example-bank.com │
│ Access-Control-Allow-Methods: GET, POST │
│ Access-Control-Allow-Headers: │
│ Authorization, Content-Type │
│ Access-Control-Max-Age: 600 │
│◀───────────────────────────────────────────│
│ 3. GET /v1/accounts/me (real request) │
│───────────────────────────────────────────▶│
│ 4. 200 + Access-Control-Allow-Origin │
│◀─── (needed again, or JS can't read it) ───│
Access-Control-Allow-Credentials: true is the dangerous one: the page may send cookies and read
the reply. The browser refuses to combine it with
Access-Control-Allow-Origin: *. Developers then "fix" that by echoing back the request's origin,
which is * with the safety catch removed.
4. The CORS mistakes that matter
| Mistake | Example | Why it hurts |
|---|---|---|
| Reflect any origin + credentials | ACAO: <whatever Origin said> |
Any site reads logged-in users' data |
Allow null |
ACAO: null |
Sandboxed iframes and file: pages send Origin: null, so attackers can too |
| Suffix or prefix match | endsWith("example-bank.com") |
evilexample-bank.com passes |
| Unescaped regex dot | https://api.example-bank.com as a regex |
. matches any character: apixexample-bank.com |
| Wildcard subdomains | https://.*\.example-bank\.com |
One XSS or taken-over subdomain (L08) can read everything |
Allow http:// origins |
http://portal.example-bank.com |
A network attacker can inject script into the HTTP page |
Reflect without Vary: Origin |
Cached response with one origin | A cache serves site A's CORS answer to site B |
ACAO: * without credentials is fine for public data such as exchange rates. It isn't fine for an
internal API whose only "authentication" is being reachable from inside the network.
🧪 Worked example: the Friday fix in Kong
The advisor portal (https://advisor.example-bank.com) logs in with a session cookie that Kong
validates. The CORS errors went away after this change:
plugins:
- name: cors
route: accounts-v1
config:
origins: ["*"]
credentials: true
Browsers reject * with credentials, so Kong's plugin source (checked against 3.x; confirm for your
version) handles this case by echoing the request's Origin with
Access-Control-Allow-Credentials: true. An empty origins list behaves the same way. A test from your own laptop against a dev route shows it:
$ curl -s -o /dev/null -D - \
-H 'Origin: https://evil.example.net' \
https://dev-api.example-bank.com/v1/accounts/me
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://evil.example.net ◀ reflected
Access-Control-Allow-Credentials: true ◀ cookies OK
Vary: Origin
Any page an advisor visits can now fetch(url, {credentials: "include"}) and read client accounts.
The fix lists exact origins, methods and headers:
plugins:
- name: cors
route: accounts-v1
config:
origins:
- https://advisor.example-bank.com
- https://portal.example-bank.com
methods: [GET, POST]
headers: [Authorization, Content-Type, X-Request-ID]
credentials: true
max_age: 600
- name: response-transformer
route: accounts-v1
config:
add:
headers:
- "Strict-Transport-Security:max-age=31536000;
includeSubDomains"
- "X-Content-Type-Options:nosniff"
- "Content-Security-Policy:default-src 'none';
frame-ancestors 'none'"
- "Cache-Control:no-store"
- "Referrer-Policy:no-referrer"
Two details from Kong's source. An entry made only of letters, digits, ., :, / and - is
compared as an exact string; anything else is treated as a regex (anchored at both ends). And
response-transformer's add skips a header the service already set, so a service can still send
a stricter value.
🛠 Try it (optional, ~5 min)
Compare the headers of two public sites with read-only HEAD requests:
H='^(strict|x-frame|x-content|x-xss|referrer|content-sec|server:)'
for site in https://github.com https://example.com; do
echo "== $site"
curl -sI "$site" | grep -iE "$H" | cut -c1-68
done
Real output (November 2026; cut trims long lines to 68 characters):
== https://github.com
strict-transport-security: max-age=31536000; includeSubdomains; prel
x-frame-options: deny
x-content-type-options: nosniff
x-xss-protection: 0
referrer-policy: origin-when-cross-origin, strict-origin-when-cross-
content-security-policy: default-src 'none'; base-uri 'self'; child-
server: github.com
== https://example.com
server: cloudflare
GitHub sets the whole short list, including x-xss-protection: 0 and preload. The example page
sets none, which is reasonable for a static page with no logins. Now score a dev route you own
against section 2's table.
🧠 Key takeaways
- Security headers instruct browsers; non-browser clients ignore them, so they protect people, not servers.
- For JSON APIs: HSTS,
nosniff,default-src 'none'; frame-ancestors 'none',no-store. - The same-origin policy blocks reading, not sending; CORS loosens it for named origins.
- Never reflect arbitrary origins with credentials, never allow
null, and match exact origins. - In Kong,
origins: ["*"]pluscredentials: trueechoes any origin back.
📖 Words to know
| Term | Meaning |
|---|---|
| HSTS | Strict-Transport-Security: tells the browser to use only HTTPS for a host for max-age seconds |
| SSL stripping | A network attacker keeping the victim on plain HTTP while talking HTTPS to the real site |
| MIME sniffing | A browser guessing a response's type from its content instead of Content-Type |
| Clickjacking | Tricking a user into clicking inside an invisible frame of your page |
| Origin | Scheme + host + port, such as https://api.example-bank.com:443 |
| Same-origin policy | Browser rule: pages may send cross-origin requests but not read the responses |
| CORS | Server headers that let named origins read cross-origin responses |
| Preflight | The OPTIONS request a browser sends to ask whether a non-simple request is allowed |
Vary: Origin |
Tells caches the response differs by Origin, so one site's answer isn't reused for another |
⏭ Next time
L38 is the misconfiguration lesson: a Spring Boot /actuator/heapdump handed researchers live AWS
keys for a carmaker's cloud, and a Kong Admin API on the wrong port can reroute every request.
HSTS preload: the one-way door
HSTS only protects a browser that has already seen the header. The very first request, or the first
one after max-age runs out, can still go out over plain HTTP. The preload list closes that gap:
a list of domains compiled into Chrome, and also used by Firefox, Safari and Edge, that are
HTTPS-only before the browser ever contacts them.
How the pieces fit
Strict-Transport-Security:
max-age=31536000; includeSubDomains; preload
│ │ │
│ │ └ "please add me" to the list
│ └ every subdomain, at any depth
└ seconds (1 year); 0 = forget me
The header is ignored when it arrives over HTTP (an attacker could inject it), and on HTTPS with a broken certificate. Once a host is known as HSTS, certificate errors on it become hard failures: the user can't click through. Good for security, bad if you let a certificate expire (L11).
Submitting a domain at hstspreload.org requires, at the time of writing: a valid certificate, an
HTTP-to-HTTPS redirect on the same host, HTTPS on every subdomain, and the header on the bare domain
with max-age of at least one year, includeSubDomains and preload. Some whole top-level domains
are preloaded: every .dev and .app site is HTTPS-only by definition.
Why it's a one-way door
Getting on the list takes a browser release. Getting off takes a removal request and another
release, and until users update, their browsers still enforce it. That is months, not minutes.
includeSubDomains is the part that bites, because it covers hosts you forgot:
example-bank.com preloaded, includeSubDomains
├─ www.example-bank.com HTTPS ok
├─ api.example-bank.com HTTPS ok
├─ reports.example-bank.com HTTP only (old vendor) ✗ unreachable
└─ printer-3f.example-bank.com plain HTTP admin UI ✗ unreachable
Worked example: rolling it out safely
A ramp that's easy to undo:
- Inventory every subdomain, using your DNS zones and certificate transparency logs (L11).
- Send
max-age=300(5 minutes) withoutincludeSubDomains. Watch for breakage. - Go to
max-age=86400, then a week, then addincludeSubDomainsonce the inventory is clean. - Only then use
max-age=31536000; includeSubDomains; preloadand submit.
To back out before preloading, send max-age=0: each browser forgets the host on its next HTTPS
visit. After preloading, max-age=0 doesn't help until the removal ships.
HSTS for APIs
Machine clients mostly ignore HSTS. curl can honour it only if you pass --hsts <file> (7.74.0 and
later); Java and Python clients don't. So for an API, the real control is not listening on HTTP. If
http://api.example-bank.com/v1/... returns a redirect, the client has already sent its bearer
token in plaintext before learning it should have used HTTPS. Many teams therefore refuse HTTP on
API hosts (close port 80, or return an error) instead of redirecting, while still sending HSTS for
the occasional browser. On AWS, CloudFront has a managed SecurityHeadersPolicy that adds HSTS and
friends; an ALB doesn't add response headers, so Kong or the service has to.
🧠 Extra takeaways
- HSTS is accepted only over valid HTTPS and only protects browsers after their first visit.
- Preloading removes the first-visit gap but takes months to undo.
- Ramp
max-ageup slowly and inventory subdomains beforeincludeSubDomains. - For APIs, refuse plain HTTP instead of redirecting: the token is already exposed by the redirect.
Testing your own CORS policy on localhost
CORS bugs hide in the "is this origin allowed?" function. It's usually a few lines, easy to get wrong, and easy to test once you know which origins to try. Here's a deliberately buggy server you can run on your own machine.
The lab
Save this as cors_lab.py (Python 3 standard library only, listens on 127.0.0.1 only):
# cors_lab.py: a deliberately buggy CORS policy. Localhost only.
from http.server import BaseHTTPRequestHandler, HTTPServer
def allowed(origin):
return origin.endswith("example.com") or origin == "null"
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
origin = self.headers.get("Origin", "")
self.send_response(200)
if allowed(origin):
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Access-Control-Allow-Credentials",
"true")
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"accountId": "ACC-1001", "balance": 1.5}')
HTTPServer(("127.0.0.1", 8037), Handler).serve_forever()
Run python3 cors_lab.py in one terminal, and this loop in another:
U=http://127.0.0.1:8037/
for o in https://app.example.com https://evilexample.com \
http://app.example.com null https://example.com.evil.net; do
a=$(curl -s -o /dev/null -D - -H "Origin: $o" "$U" \
| grep -i '^access-control-allow-origin' | cut -d' ' -f2 \
| tr -d '\r')
printf '%-30s ACAO: %s\n' "$o" "${a:-(none)}"
done
Real output:
https://app.example.com ACAO: https://app.example.com
https://evilexample.com ACAO: https://evilexample.com
http://app.example.com ACAO: http://app.example.com
null ACAO: null
https://example.com.evil.net ACAO: (none)
Reading the results
origin tried should be got bug
https://app.example.com allowed allowed -
https://evilexample.com blocked allowed suffix match
http://app.example.com blocked allowed scheme ignored
null blocked allowed null trusted
https://example.com.evil.net blocked blocked (a prefix
check fails)
Each row is a different attacker. Anyone can register evilexample.com. The http:// page can be
rewritten by anyone on the user's network. null comes from a sandboxed iframe the attacker
controls. The last row didn't fail here, but a startsWith("https://example.com") check would let
it through: that's why the list of origins to try includes both a suffix and a prefix trick.
The fix
ALLOWED = {"https://app.example.com",
"https://portal.example.com"}
def allowed(origin):
return origin in ALLOWED # exact match, scheme included
Also send Vary: Origin on every response whose CORS headers depend on the request's origin, so a
cache in front of you doesn't replay one origin's answer to another. Spring's CorsConfiguration
with allowedOrigins (exact strings) and Kong's origins with literal entries both do exact
matching; the risky variants are allowedOriginPatterns in Spring and regex entries in Kong. Rerun
the loop after the fix: only the first row should get a header. Stop the server with Ctrl-C.
The same five origins work against a dev route of your own behind a gateway such as Kong. Add one more:
https://evil.example.net, which should never be allowed anywhere.
🧠 Extra takeaways
- Test origin checks with: an exact match, a suffix trick, a prefix trick,
http://, andnull. - Exact set membership beats string tricks and regexes.
- Every response with origin-dependent CORS headers needs
Vary: Origin. - Pattern features (Spring
allowedOriginPatterns, Kong regex entries) deserve extra review.
The quiz for this lesson and the Tests section need JavaScript.