Web Application Security · intermediate · ~12 min

Security headers, CORS, and JWT weaknesses

**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.

Overview

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.

  • Security headers are response headers the server sends to instruct the browser to enforce extra protections (block mixed content, refuse framing, force HTTPS). Their absence rarely causes a breach alone, but it removes a safety layer.
  • CORS (Cross-Origin Resource Sharing) decides which other origins are allowed to read a response. The classic bug is reflecting whatever Origin the request sent back into Access-Control-Allow-Origin while also sending Access-Control-Allow-Credentials: true.
  • JWT (JSON Web Token) is a compact, signed set of claims (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.

Why it matters

In authorized professional work these three areas show up on almost every web assessment, and they span the full risk range.

  • Header gaps are the routine, defensible-baseline findings. On their own they are usually low severity, but a missing 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.
  • Credentialed-CORS reflection is a genuinely serious finding: it can let any attacker-controlled website read a logged-in victim's authenticated API responses (profile data, tokens, account details) simply because the victim visited a malicious page while logged in. That is real data exposure, not theory.
  • JWT mistakes are among the highest-impact web findings because they can yield a complete authentication bypass. If the server accepts 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.

Core concepts

1. Security response headers (defence-in-depth)

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."

2. CORS misconfiguration

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:

  • Request: Origin: https://evil.example
  • Response: Access-Control-Allow-Origin: https://evil.example and Access-Control-Allow-Credentials: true

Now 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.

3. JWT weaknesses

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.
  • Algorithm confusion (RS256 → HS256). The server expects RS256 (asymmetric: verify with the public key). The attacker changes the header to HS256 (symmetric HMAC) and signs with the public key as the secret. If the server uses the same key material for verification without pinning the algorithm, the forged token verifies.
  • Weak HMAC secret. A short/guessable HS256 secret can be brute-forced offline from one captured token, then used to mint valid tokens.
  • Unchecked exp. The library verifies the signature but the app never checks expiry, so old (or stolen) tokens live forever.
  • Secrets in the payload. The payload is base64url-encoded, not encrypted. Anyone can decode it and read whatever is inside.

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.

  1. What asset is protected when you pin the JWT algorithm to a single expected value?
  2. Where is the trust boundary for a CORS decision — which specific request header must never be trusted as-is?
  3. Which insecure assumption makes alg: none work, and which server-side log line would let you detect that someone is trying it?
  4. Why must the brute-forcing of a weak HMAC secret only ever be done against a token from a lab you own?

Syntax notes

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.

Lesson

This lesson is a set of high-frequency findings around headers, cross-origin policy, and tokens.

Security headers (defence-in-depth, easy wins)

Defence-in-depth means layering several protections so that one failure does not break everything. Useful response headers include:

  • Content-Security-Policy (CSP) — restricts which origins scripts and other resources may load from. This is the strongest anti-XSS layer.
  • Strict-Transport-Security (HSTS) — forces browsers to use HTTPS.
  • X-Content-Type-Options: nosniff — stops the browser from guessing content types.
  • X-Frame-Options / frame-ancestors — anti-clickjacking (stops your page being embedded in a hostile frame).
  • Referrer-Policy — controls how much referrer information is sent.

Their absence is a routine, low-but-real finding.

CORS misconfiguration

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:

  • Browsers forbid * together with credentials, so that is not the real risk.
  • Reflecting arbitrary origins is the actual bug.

The fix is to allowlist exact, trusted origins.

JWT weaknesses

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.
  • Algorithm confusion (RS256 to HS256) — the server verifies using the public key as an HMAC secret, which lets an attacker forge tokens.
  • Weak HMAC secret — a guessable secret can be brute-forced, then used to forge tokens.
  • No expiry, or expiry not checked.
  • Sensitive data in the payload — the payload is base64-encoded, not encrypted, so anyone can read it.

The fix:

  • Pin the algorithm.
  • Verify the signature properly.
  • Use strong secrets and keys.
  • Validate exp, aud, and iss.
  • Never trust an unverified token.

Code examples

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.

Line by line

Walking the secure verifier and its test:

  1. 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.
  2. 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.
  3. options={"require": ["exp", "iat"]} forces the token to carry an expiry and issued-at; a token without exp is rejected rather than living forever.
  4. audience / issuer ensure the token was minted for this API by the expected issuer. A valid token stolen from another service will fail here.
  5. The 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.

Common mistakes

Mistake 1 — Reading claims from a decode-only call.

  • Wrong: claims = jwt.decode(token, options={"verify_signature": False}) and then trusting claims["admin"].
  • Why wrong: decoding is just base64url; the signature was never checked, so the claims are attacker-controlled.
  • Corrected: always verify with a pinned algorithms=[...] and a real key before trusting any claim.
  • Recognise/prevent: grep for verify_signature set to False and for jwt.decode calls missing an algorithms argument.

Mistake 2 — Letting the token pick its own algorithm.

  • Wrong: passing algorithms=["HS256", "RS256", "none"] "to be flexible."
  • Why wrong: it re-enables alg: none and algorithm confusion.
  • Corrected: pin exactly the one algorithm that matches the key you hold.
  • Recognise/prevent: one key, one algorithm; review any allowlist with more than one entry.

Mistake 3 — Reflecting the Origin in CORS.

  • Wrong: Access-Control-Allow-Origin: <echo request Origin> plus Allow-Credentials: true.
  • Why wrong: any website becomes an allowed origin and can read authenticated responses.
  • Corrected: check the incoming origin against an exact allowlist; only then echo it, or return a fixed origin.
  • Recognise/prevent: in a proxy, send Origin: https://evil.example and see whether it comes back in the response.

Mistake 4 — Substring origin checks.

  • Wrong: if "mycompany.com" in origin:
  • Why wrong: matches mycompany.com.evil.example and evil-mycompany.com.
  • Corrected: compare the full origin string to entries in an allowlist set.

Mistake 5 — Putting secrets in the JWT payload.

  • Wrong: storing a password, API key, or full card number in a claim.
  • Why wrong: the payload is encoded, not encrypted; anyone with the token reads it.
  • Corrected: keep only non-sensitive identifiers in claims; store secrets server-side.

Debugging tips

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?

Memory safety

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):

  • Timestamp (UTC) and a correlation/request id so events can be tied together.
  • Source: client IP and, where available, the account/subject id.
  • Resource: the endpoint or action requested.
  • The security decision and result: accept / reject, and a category of reason (expired, bad_signature, alg_not_allowed, origin_not_allowlisted).
  • The offending Origin string on a rejected CORS request (it is not secret and helps spot campaigns).

Never log:

  • The JWT itself, the signing secret, or private keys.
  • Passwords, session cookies, or bearer tokens.
  • Full PANs or unneeded PII in claims.

Events that signal abuse:

  • A spike in alg_not_allowed or bad_signature rejections — someone is probing token forgery.
  • Many origin_not_allowlisted rejections from one source — CORS probing.
  • The same subject id arriving from many IPs in a short window — possible token theft/replay.

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.

Real-world uses

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.

Practice tasks

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.

  • Objective: enumerate which security headers a local app sends.
  • Requirements: start a small local web app; capture a response in your proxy; produce a table of present vs. missing headers (CSP, HSTS, nosniff, frame-ancestors, Referrer-Policy).
  • Output: the table plus a one-line risk note per missing header.
  • Hints: view the raw response; do not rely on the browser's rendered view.
  • Defensive conclusion: add the missing headers, re-capture, and confirm they now appear.

Beginner 2 — Decode vs. verify.

  • Objective: show that decoding a JWT proves nothing about trust.
  • Requirements: take a lab token, base64url-decode the payload by hand or with a decode-only call, then attempt to verify it with the wrong key.
  • Input/Output: input = one lab JWT; output = the readable claims AND a note that verification with a wrong key fails.
  • Constraints: do not use a real production token.
  • Hints: PyJWT options={"verify_signature": False} decodes only.
  • Defensive conclusion: write one sentence explaining why "I can read the claims" is not "the claims are trustworthy."

Intermediate 1 — CORS reflection detection and fix.

  • Objective: detect and remediate credentialed CORS reflection in a lab API.
  • Requirements: send Origin: https://evil.example and check whether it is reflected with Allow-Credentials: true; then implement an exact-match allowlist.
  • Constraints: lab only; do not point the request at any third party.
  • Hints: test both an allowlisted origin (accepted) and a random one (rejected).
  • Defensive conclusion (remediate + verify): after the fix, re-send evil.example and confirm it is not reflected, while the real origin still works.

Intermediate 2 — Pin the algorithm.

  • Objective: harden a verifier that currently accepts alg: none.
  • Requirements: start from a verifier that omits algorithms; add the pin and claim validation (exp, aud, iss).
  • Input/Output: feed it a forged none token (must reject) and a valid token (must accept).
  • Hints: reuse the test shape from the lesson.
  • Defensive conclusion: keep the forged token in a regression test so the flaw cannot silently return.

Challenge — Weak-secret detection in your own lab.

  • Objective: demonstrate why a weak HS256 secret is dangerous, then remediate.
  • Requirements: in a lab you fully own, mint a token with a deliberately weak secret; using a wordlist you control, show that the secret is recoverable offline from the token; then rotate to a 32+ byte random secret and show recovery is no longer feasible.
  • Constraints: the token, secret, and wordlist must all be yours; never target a third-party token. Include cleanup: delete the weak secret and any minted tokens afterward.
  • Hints: the recovery step attacks the HMAC over header.payload; strong secrets defeat it.
  • Defensive conclusion (remediate + verify): after rotation, re-run the recovery attempt and confirm it fails; add secret-strength to your deployment checklist and log signature-failure spikes as an abuse signal.

Summary

Main concepts. Three findings, one root cause — trusting unverified input.

  • Security headers add defence-in-depth: set 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).
  • CORS: never reflect an arbitrary request Origin alongside Allow-Credentials: true. Use an exact-match allowlist; * with credentials is blocked by the browser, so reflection is the real bug.
  • JWT: pin the algorithm (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.

Practice with these exercises