Web Application Security · intermediate · ~12 min
**What you will learn** - Audit a web application's response headers (CSP, HSTS, `nosniff`, `frame-ancestors`, `Referrer-Policy`) and explain what each one defends against. - Recognise a dangerous CORS configuration where the server reflects an arbitrary `Origin` while allowing credentials, and replace it with an exact allowlist. - Identify the common JWT flaws — `alg: none`, RS256-to-HS256 confusion, weak HMAC secrets, unchecked `exp`, and secrets stored in the payload. - Verify a JWT correctly: pin the algorithm, verify the signature, and validate the claims (`exp`, `aud`, `iss`). - Write a mitigation-verification test that proves a fix rejects forged/expired tokens and accepts valid ones. - Log the security decisions (accept/reject, and why) without ever logging the token or secret itself.
Security objective. The asset you are protecting is authenticated identity and authenticated data — the guarantee that when a browser or API says "I am user 42, and I am an admin," the server can trust it, and that another website cannot silently read user 42's private responses. The threats are (1) token forgery leading to full authentication bypass, (2) cross-origin theft of authenticated data, and (3) the loss of defence-in-depth when protective response headers are missing. By the end you will be able to detect these weaknesses in an authorized lab and prevent them with concrete configuration and code fixes.
This lesson pulls together three high-frequency web findings that share one root cause: trusting input that was never verified.
Origin the request sent back into Access-Control-Allow-Origin while also sending Access-Control-Allow-Credentials: true.header.payload.signature). The dangerous flaws all come down to the server accepting a token whose signature it never truly checked.This builds directly on your prereqs. From "Testing the web: intercepting and shaping requests" you already know how to view and modify raw HTTP requests and responses in a proxy — that is exactly how you will inspect headers, tamper with an Origin, and swap a token. From "Cookies, sessions, and browser storage" you know how session state is carried between browser and server; a JWT is just another way to carry that state, and CORS-with-credentials is about who is allowed to use your session cookie cross-origin. The fixes here all follow one pattern: allowlist trusted origins, pin the algorithm, verify the signature, and validate the claims.
In authorized professional work these three areas show up on almost every web assessment, and they span the full risk range.
Content-Security-Policy turns a reflected-input bug into a working XSS, and a missing frame-ancestors turns a sensitive action into a clickjacking target. They are cheap to fix and cheap to verify, which makes them a fast win in a report.alg: none or is fooled by algorithm confusion, an attacker forges a token that says admin: true and walks in. There is no lateral movement needed — the front door opens.Professionally, knowing these lets you both find the issue and write the remediation and retest steps a developer can actually apply. That defensive half — the exact header value, the allowlist code, the verification snippet, the test that proves it — is what makes a finding useful rather than just alarming.
Definition. HTTP response headers the server sends to tell the browser to enforce additional client-side protections.
Plain explanation. The server cannot control the user's browser directly, but it can ask the browser to be stricter. "Defence-in-depth" means layering several independent protections so one failure does not collapse everything.
How it works. The browser reads these headers on each response and changes its behaviour accordingly.
| Header | Defends against | Effect |
|---|---|---|
Content-Security-Policy |
XSS, data injection | Restricts which origins scripts/styles/images may load from |
Strict-Transport-Security |
HTTPS downgrade, SSL-strip | Forces the browser to use HTTPS for future visits |
X-Content-Type-Options: nosniff |
MIME confusion attacks | Stops the browser guessing content types |
X-Frame-Options / CSP frame-ancestors |
Clickjacking | Stops your page being embedded in a hostile frame |
Referrer-Policy |
Referrer leakage | Limits how much URL info is sent to other sites |
When / when not. Always set them on HTML and sensitive API responses. CSP needs care — a policy too strict breaks the app, so it is rolled out gradually (often via Content-Security-Policy-Report-Only first).
Pitfall. A CSP that includes unsafe-inline for scripts largely defeats its own anti-XSS purpose. "Header present" is not "header effective."
Definition. CORS is the browser mechanism that decides whether JavaScript on origin A may read a response from origin B.
Plain explanation. By default the browser blocks cross-origin reads. The server can opt in by sending Access-Control-Allow-Origin. The danger is opting in for everyone while also allowing credentials (cookies).
How it works. The dangerous pattern is the server reading the request's Origin header and echoing it straight back:
Origin: https://evil.exampleAccess-Control-Allow-Origin: https://evil.example and Access-Control-Allow-Credentials: trueNow evil.example can make credentialed requests (sending the victim's cookies) and read the authenticated response.
When / when not. Reflecting an origin is only safe if you first check it against an exact allowlist. Note the browser forbids the wildcard * together with credentials — so * is not the real risk; reflecting arbitrary origins is.
Pitfall. Sloppy allowlist checks like "does the origin contain mycompany.com" match mycompany.com.evil.example. Match the full origin exactly.
Definition. A JWT is three base64url parts — header.payload.signature — where the header names the signing algorithm, the payload carries claims (like sub, admin, exp), and the signature proves integrity.
Plain explanation. The signature is the whole point: it lets the server detect if anyone changed the claims. Every classic JWT flaw is a way the server ends up not actually checking that signature.
How it works — the common flaws:
alg: none. The token declares "no algorithm," the signature is empty, and a naive library accepts it. Attacker forges any claims.exp. The library verifies the signature but the app never checks expiry, so old (or stolen) tokens live forever.Critical misconception to correct: decoding a JWT (base64url) is not verifying it. A decoded, readable payload tells you nothing about whether the signature is valid. Verification requires checking the signature with the correct key and the pinned algorithm.
When / when not. Pin exactly one expected algorithm per key. Never let the token's own header choose the verification algorithm.
Pitfall. Trusting claims from jwt.decode() (decode-only) instead of jwt.verify() — reading the payload before the signature is confirmed.
THREAT MODEL — token auth over the web
ASSET: authenticated identity + authenticated data
[ Browser / API client ] [ Attacker page: evil.example ]
| (holds cookie / JWT) | (wants victim's data
| | or an admin token)
v v
==================== TRUST BOUNDARY ==========================
Entry points the server must NOT blindly trust:
- request Origin header -> CORS decision
- Authorization: Bearer JWT -> signature + claims
- token 'alg' header field -> MUST be pinned, never obeyed
===============================================================
|
v
[ Web / API server ] --verify--> [ Auth logic ] --> [ User data / DB ]
sets response headers (CSP/HSTS/...)
enforces CORS allowlist
verifies JWT sig, algorithm, exp/aud/iss
Insecure assumption in each bug:
- "the Origin the browser sent is a partner" (CORS reflection)
- "the alg the token names is the one we meant" (alg confusion / none)
- "a decodable payload is a trusted payload" (decode != verify)
Knowledge check.
alg: none work, and which server-side log line would let you detect that someone is trying it?Two structures matter most: how you set the protective headers, and how you verify a JWT with the algorithm pinned.
Secure response headers (server config / framework — illustrative):
Content-Security-Policy: default-src 'self'; script-src 'self'; frame-ancestors 'none'
Strict-Transport-Security: max-age=63072000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
frame-ancestors 'none' = page cannot be framed (anti-clickjacking).max-age on HSTS is in seconds; only send it over HTTPS.Pinned JWT verification (Python PyJWT — a real library):
import jwt # PyJWT
# algorithms is an ALLOWLIST — the token's own header cannot override it
claims = jwt.decode(
token,
key=SECRET,
algorithms=["HS256"], # pin: never accept 'none' or a swapped alg
options={"require": ["exp"]}, # demand an expiry claim
audience="my-api", # validate aud
issuer="https://auth.mylab", # validate iss
)
The key annotations: algorithms=[...] is the single most important argument — it is the pin that stops alg: none and algorithm confusion. jwt.decode in PyJWT does verify the signature (despite the name); jwt.decode(..., options={"verify_signature": False}) would only decode, which you use for inspection, never for trust.
This lesson is a set of high-frequency findings around headers, cross-origin policy, and tokens.
Defence-in-depth means layering several protections so that one failure does not break everything. Useful response headers include:
Their absence is a routine, low-but-real finding.
CORS (Cross-Origin Resource Sharing) controls which origins may read a response from another origin.
The dangerous mistake is reflecting the request Origin back in Access-Control-Allow-Origin together with Allow-Credentials: true. When a server does this, any website can make authenticated cross-origin reads against it.
Two notes:
* together with credentials, so that is not the real risk.The fix is to allowlist exact, trusted origins.
A JSON Web Token (JWT) is a set of signed claims in the form header.payload.signature. Common flaws:
alg: none — the token is accepted with no signature, so an attacker can forge any claims.The fix:
exp, aud, and iss.The example uses a small Python auth check. It shows an intentionally broken verifier, the corrected verifier, and a test proving the fix rejects bad tokens and accepts good ones. Run it only on your own machine.
(1) WARNING: intentionally vulnerable — use only in a local, isolated, authorized lab. Do not deploy.
# insecure_auth.py -- DO NOT DEPLOY. Local lab only.
import jwt # PyJWT
SECRET = "devsecret" # also weak; placeholder for a lab
def check_token_INSECURE(token: str) -> dict:
# BUG 1: algorithms not pinned -> library may accept 'alg: none'
# BUG 2: no expiry / audience / issuer validation
# BUG 3: trusting claims without a pinned algorithm = forgeable
return jwt.decode(
token,
key=SECRET,
algorithms=["HS256", "none"], # accepting 'none' is the fatal flaw
options={"verify_signature": False}, # worse: signature not checked at all
)
# An attacker crafts {"admin": true} with NO signature and it is accepted.
Why it is vulnerable: with verify_signature: False (or none allowed) the server reads attacker-controlled claims as if trusted. A forged admin: true grants admin — a full auth bypass.
(2) SECURE fix:
# secure_auth.py -- the corrected verifier
import jwt # PyJWT
# Use a strong, random secret in real systems (e.g. 32+ random bytes).
SECRET = "REPLACE_WITH_32+_RANDOM_BYTES_<development-placeholder>"
class AuthError(Exception):
pass
def check_token_SECURE(token: str) -> dict:
try:
claims = jwt.decode(
token,
key=SECRET,
algorithms=["HS256"], # PIN: no 'none', no alg swap
options={"require": ["exp", "iat"]},
audience="my-api", # validate aud
issuer="https://auth.mylab", # validate iss
)
except jwt.ExpiredSignatureError:
raise AuthError("token expired")
except jwt.InvalidTokenError: # covers bad sig, wrong alg, bad aud/iss
raise AuthError("token invalid")
return claims
(3) VERIFY — prove it rejects bad input and accepts good input:
# test_auth.py -- run: python test_auth.py
import time, jwt
from secure_auth import check_token_SECURE, SECRET, AuthError
def make(claims, key=SECRET, alg="HS256"):
base = {"aud": "my-api", "iss": "https://auth.mylab",
"iat": int(time.time()), "exp": int(time.time()) + 60}
base.update(claims)
return jwt.encode(base, key, algorithm=alg)
# ACCEPT: a valid, unexpired, correctly-signed token
good = make({"sub": "user42"})
assert check_token_SECURE(good)["sub"] == "user42"
# REJECT 1: forged 'alg: none' token
forged = jwt.encode({"sub": "admin", "aud": "my-api",
"iss": "https://auth.mylab", "exp": 9999999999},
key=None, algorithm="none")
try:
check_token_SECURE(forged); print("FAIL: none accepted")
except AuthError: print("OK: none rejected")
# REJECT 2: token signed with the WRONG secret
wrong = make({"sub": "user42"}, key="attacker-guess")
try:
check_token_SECURE(wrong); print("FAIL: bad sig accepted")
except AuthError: print("OK: bad signature rejected")
# REJECT 3: expired token
expired = make({"sub": "user42", "exp": int(time.time()) - 10})
try:
check_token_SECURE(expired); print("FAIL: expired accepted")
except AuthError: print("OK: expired rejected")
print("done")
Expected output (order of the OK lines is deterministic):
OK: none rejected
OK: bad signature rejected
OK: expired rejected
done
The assert on the good token passing silently, followed by three OK: lines, is your proof that the fix accepts valid and rejects forged/expired tokens.
Walking the secure verifier and its test:
SECRET is the HMAC key. In the insecure file it was devsecret — short and guessable, so brute-forceable. The secure version demands 32+ random bytes; a strong secret makes offline brute-forcing of the signature infeasible.jwt.decode(token, key=SECRET, algorithms=["HS256"], ...) — despite the name, PyJWT's decode verifies the signature. The algorithms allowlist is the pin: the token's own alg header is ignored if it is not in this list, so none and an RS256→HS256 swap are both blocked.options={"require": ["exp", "iat"]} forces the token to carry an expiry and issued-at; a token without exp is rejected rather than living forever.audience / issuer ensure the token was minted for this API by the expected issuer. A valid token stolen from another service will fail here.except ladder converts every verification failure — expired, bad signature, wrong algorithm, wrong audience — into a single AuthError. The caller learns "denied," not why in detail (avoid leaking which check failed to clients).In the test, follow the values:
| Token | alg | signature | exp | Expected result |
|---|---|---|---|---|
good |
HS256 | valid (right secret) | +60s | accepted |
forged |
none | empty | far future | rejected (alg not pinned-in) |
wrong |
HS256 | invalid (wrong secret) | +60s | rejected (bad signature) |
expired |
HS256 | valid | −10s | rejected (expired) |
Only the first row survives every gate — signature, algorithm, and expiry all pass — which is precisely the behaviour a sound verifier must have.
Mistake 1 — Reading claims from a decode-only call.
claims = jwt.decode(token, options={"verify_signature": False}) and then trusting claims["admin"].algorithms=[...] and a real key before trusting any claim.verify_signature set to False and for jwt.decode calls missing an algorithms argument.Mistake 2 — Letting the token pick its own algorithm.
algorithms=["HS256", "RS256", "none"] "to be flexible."alg: none and algorithm confusion.Mistake 3 — Reflecting the Origin in CORS.
Access-Control-Allow-Origin: <echo request Origin> plus Allow-Credentials: true.Origin: https://evil.example and see whether it comes back in the response.Mistake 4 — Substring origin checks.
if "mycompany.com" in origin:mycompany.com.evil.example and evil-mycompany.com.Mistake 5 — Putting secrets in the JWT payload.
Headers not appearing. If a header you configured is missing from the response, check: is it set on this route (some frameworks scope middleware)? Is a proxy/CDN stripping or overriding it? Use your intercepting proxy (from the prereq) to view the actual bytes on the wire, not what the code intends.
CSP breaks the app. If styles/scripts vanish after adding CSP, open the browser console — CSP violations are logged there with the exact blocked URL and directive. Start with Content-Security-Policy-Report-Only so violations are reported but not enforced, tighten, then switch to enforcing.
CORS request fails unexpectedly. Check whether the browser sent a preflight OPTIONS. Look at the response's Access-Control-Allow-Origin, -Methods, -Headers, and -Credentials. A missing preflight response, not the main request, is often the real failure.
JWT always rejected after adding pinning. Ask: does the token's real alg match your pinned list? Is the key correct (secret for HS256, public key for RS256 verification)? Is the clock skewed (an exp/iat a few seconds off)? PyJWT's leeway argument handles small skew.
JWT still accepted when it should not be. Confirm you are calling a verifying function, not a decode-only path, and that algorithms does not include none. Add a deliberately forged token to your test suite so a regression is caught.
Questions to ask when it fails: Am I looking at the real response or my intended config? Which check rejected the token — signature, algorithm, or claim? Is a caching layer serving a stale response? Does my test actually cover the forged case, or only the happy path?
Security & safety — detection and logging.
For each authentication or CORS decision, log enough to investigate abuse but never enough to become a leak.
Log (structured):
accept / reject, and a category of reason (expired, bad_signature, alg_not_allowed, origin_not_allowlisted).Origin string on a rejected CORS request (it is not secret and helps spot campaigns).Never log:
Events that signal abuse:
alg_not_allowed or bad_signature rejections — someone is probing token forgery.origin_not_allowlisted rejections from one source — CORS probing.False positives arise from: legitimate clock skew (fix with small leeway), a newly deployed partner origin not yet added to the allowlist, or a client library upgrade that changes the default algorithm. Investigate patterns, not single events, and confirm before blocking.
Authorization reminder. Every technique here — sending a forged alg: none token, brute-forcing a weak secret, tampering with Origin — is only done against systems you own or are explicitly authorized to test (localhost, a container, an intentionally-vulnerable VM, or a CTF). Doing it to a third party is unlawful regardless of intent.
Concrete authorized use case. A company hires you to assess its customer portal. On a scoped, written-authorized engagement you (1) capture responses in a proxy and note missing Content-Security-Policy and Strict-Transport-Security headers; (2) send Origin: https://evil.example to the API and observe it reflected back with Allow-Credentials: true — a credentialed-CORS exposure; (3) capture a JWT from your own test account and confirm the server accepts an alg: none variant in a local copy of the app. Each finding ships with a secure fix (exact header values, an origin allowlist, pinned verification) and a retest step.
Best-practice habits.
| Habit | Beginner | Advanced |
|---|---|---|
| Validation | Pin one JWT algorithm; check exp |
Validate aud/iss/nbf, enforce require, add clock leeway |
| Least privilege | Keep secrets out of the payload | Short-lived access tokens + rotation, key rotation with kid |
| Secure defaults | Ship CSP, HSTS, nosniff, frame-ancestors |
CSP nonces/hashes instead of unsafe-inline; HSTS preload |
| CORS | Exact-match allowlist, no reflection | Per-route policies; separate credentialed vs. public endpoints |
| Logging | Record accept/reject + reason category | Correlate rejection spikes; alert on forgery probing |
| Error handling | Return a generic "denied" to clients | Detailed reason logged server-side only |
The throughline: never trust unverified input — not the Origin, not the token's alg, not a decoded payload.
All tasks are lab-only: run against localhost, a container, or an intentionally-vulnerable app you control. Authorization checklist before starting: (1) you own or have explicit written permission for the target; (2) it is isolated (localhost/container/VM/CTF), not production; (3) you have a reset/cleanup plan. Each task ends by remediating and verifying.
Beginner 1 — Header audit.
nosniff, frame-ancestors, Referrer-Policy).Beginner 2 — Decode vs. verify.
options={"verify_signature": False} decodes only.Intermediate 1 — CORS reflection detection and fix.
Origin: https://evil.example and check whether it is reflected with Allow-Credentials: true; then implement an exact-match allowlist.evil.example and confirm it is not reflected, while the real origin still works.Intermediate 2 — Pin the algorithm.
alg: none.algorithms; add the pin and claim validation (exp, aud, iss).none token (must reject) and a valid token (must accept).Challenge — Weak-secret detection in your own lab.
header.payload; strong secrets defeat it.Main concepts. Three findings, one root cause — trusting unverified input.
Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options: nosniff, frame-ancestors, and Referrer-Policy. Their absence is a routine finding; "present" is not "effective" (watch unsafe-inline).Origin alongside Allow-Credentials: true. Use an exact-match allowlist; * with credentials is blocked by the browser, so reflection is the real bug.algorithms=["HS256"]), verify the signature, use a strong secret/key, and validate exp, aud, iss. The payload is base64url-encoded, not encrypted.Key syntax/commands. jwt.decode(token, key, algorithms=[...], audience=..., issuer=..., options={"require":["exp"]}); header values like Content-Security-Policy: default-src 'self'; frame-ancestors 'none'.
Common mistakes. Trusting decode-only claims; multi-algorithm allowlists that re-enable none; reflecting Origin; substring origin checks; secrets in the payload.
What to remember. Decoding is not verifying. Passing a scanner is not proof of security, and nothing is ever "completely secure." Pin the algorithm, allowlist the origin, verify the signature, validate the claims — and log the accept/reject decision without ever logging the token or secret. Do all offensive steps only in an authorized, isolated lab, and always finish by remediating and retesting.