API Security · intermediate · ~12 min
**What you will learn** - Tell apart the common API authentication schemes — API keys, bearer tokens/JWT, and OAuth 2.0 — and where each one tends to fail. - Map the token *lifecycle* (issue -> transmit -> store -> validate -> expire -> revoke) and identify the leakage vectors at each stage. - Walk the OAuth 2.0 Authorization Code flow and name the three parameters that most often break: `redirect_uri`, `state`, and `scope`. - Build a strict, allowlist-based `redirect_uri` validator and prove it rejects attacker callbacks while accepting the legitimate one. - Correct the common myth that *decoding* a JWT is the same as *verifying* it, and validate issuer, audience, and expiry server-side. - Design detection and logging for token abuse without ever logging the tokens themselves.
Security objective. The asset you are protecting is the access token — the credential that lets a client act as a user or service on an API. The threat is an attacker who steals or forges that token (through a leaky URL, a loose OAuth redirect, or an unverified signature) and then calls the API as someone else. By the end of this lesson you will be able to detect weak token handling during an authorized API test and prevent it as a defender.
APIs usually authenticate with tokens, not browser session cookies. A token is a secret string the client attaches to every request to prove who it is. This changes where things go wrong: instead of one server-side session, you now have a portable secret that travels across networks, gets stored on clients, and can be copied.
The three token families you will meet are API keys (static per-client secrets), bearer tokens — frequently JWTs (signed, self-contained tokens) — and OAuth 2.0 tokens (short-lived access tokens plus longer-lived refresh tokens issued by a delegated-authorization flow).
This builds directly on your prerequisites. From Cookies, sessions, and browser storage you already know how the browser stores credentials and why localStorage is reachable by XSS while HttpOnly cookies are not — that same reasoning decides where a token is safe to keep. From Security headers, CORS, and JWT weaknesses you know that a JWT's trust depends entirely on its signature, that alg:none and weak secrets defeat it, and that CORS controls which origins may read a response — all of which apply the moment a token rides in an Authorization header.
The defenses are short to state and the rest of the lesson makes them concrete: use short-lived, narrowly-scoped tokens; enforce a strict redirect_uri allowlist; always send the state parameter; transmit tokens only in headers; and verify issuer, audience, signature, and expiry on the server.
Token mishandling is one of the leading causes of real API compromise, and it shows up in nearly every professional API assessment.
state flaws enable account takeover. A loose redirect_uri allowlist lets an attacker capture the authorization response; a missing state lets them stitch a victim's account to an attacker-controlled login (login CSRF).Referer header; tokens get committed to public repositories; verbose error pages echo them back. Much of professional API work is knowing exactly these places to look.For a defender, getting token handling right is high-leverage: short lifetimes, tight scopes, header-only transmission, and server-side validation eliminate whole categories of finding at once. For an authorized tester, knowing the schemes tells you precisely where to probe and — just as importantly — how to write a remediation that actually closes the gap.
Each concept below is taught on its own: what it is, how it works, when it applies, and the pitfall that bites people.
Definition. A static secret string assigned to a client (an app or service), sent on each request to identify it.
How it works. The server stores a hash of the key and compares it on each call, usually passed in a header like X-API-Key: <key> or Authorization: Bearer <key>.
When to use / not. Fine for server-to-server identification of a client. Poor for identifying a user, and poor when it cannot be rotated or scoped.
Pitfall. API keys are frequently long-lived, over-permissioned, and pasted into URLs or committed to code. A single leaked key can be a full breach because it often has no expiry and broad scope.
Definition. A bearer token is any token where mere possession grants access ("the bearer"). A JWT (JSON Web Token) is one common format: three Base64URL parts — header, payload, signature — joined by dots.
How it works. The client sends Authorization: Bearer <token>. For a JWT the server recomputes the signature over header.payload using the expected algorithm and key, then checks the claims (iss, aud, exp).
When to use / not. Bearer tokens suit stateless APIs. They are risky when transmitted or stored carelessly, because possession alone is enough.
Pitfall (and a myth to kill). Decoding a JWT is not verifying it. Base64URL is reversible text encoding, not encryption — anyone can read a JWT's payload with a decoder. Trust comes only from validating the signature with the correct key and algorithm. Accepting alg:none, hard-coding a weak HMAC secret, or confusing RS256 with HS256 all let an attacker forge tokens.
Definition. The stages a token passes through: issue -> transmit -> store -> validate -> expire -> revoke. A weakness at any stage undermines the rest.
How it works. Good systems keep access tokens short-lived, transmit them only in headers over TLS, store them where XSS cannot reach, validate them fully on the server, and support revocation (a deny-list or short expiry) so a stolen token stops working quickly.
Pitfall. Common leak points: tokens in URLs (logged, kept in history, sent in Referer), tokens in client storage reachable by XSS, tokens echoed in error messages, and tokens committed to repositories. "No expiry" and "no revocation" turn a small leak into a permanent one.
Definition. OAuth 2.0 lets a user grant an app limited access to their data on another service without sharing their password. Roles: the resource owner (user), the client (the app), the authorization server (issues tokens), and the resource server (the API).
How it works (Authorization Code flow). The client redirects the user to the authorization server with a client_id, requested scope, a redirect_uri, and a random state. The user approves; the authorization server redirects back to the registered redirect_uri with a one-time authorization code; the client exchanges that code (server-side, with its client secret / PKCE) for an access token and often a refresh token.
When to use / not. Use the Authorization Code flow (with PKCE for public clients). Avoid the legacy implicit flow, which returned tokens directly in the URL fragment and is now discouraged.
Pitfalls.
| Pitfall | What goes wrong | Fix |
|---|---|---|
Loose redirect_uri validation |
Attacker supplies their own callback and captures the code/token | Exact-match allowlist of registered URIs |
Missing state |
Callback is forgeable -> login CSRF / account stitching | Generate random state, bind to session, verify on return |
| Implicit flow | Token exposed in URL fragment and history | Use Authorization Code + PKCE |
Over-broad scope |
Token can do far more than needed | Request least-privilege scopes; enforce them server-side |
TRUST BOUNDARIES (each | is a boundary tokens cross)
[ Attacker ] [ Attacker-controlled site ]
| guesses/steals token ^ captures redirect
v |
+----------+ Authorization: Bearer +------------------+
| Browser/ |-------- (header) -------->| Resource API |
| Client | | (validates iss, |
| stores |<-- code / access token ---| aud, sig, exp) |
| token | +------------------+
+----------+ ^
| redirect w/ state + code | token exchange
v |
+------------------------+ register +------------------+
| Authorization Server |<-- allowlist | Client backend |
| (issues code/tokens, | of URIs | (holds secret / |
| checks redirect_uri) | | does PKCE) |
+------------------------+ +------------------+
Assets: access token, refresh token, authorization code, client secret
Entry points: Authorization header, OAuth callback (redirect_uri + state),
token-exchange endpoint, any URL that might carry a token
Trust boundary: client storage <-> network <-> resource server;
authorization server <-> registered redirect_uri only
Knowledge check
redirect_uri breaks? (Between the authorization server and the set of registered callback URIs — the server must only ever redirect to an allowlisted one.)redirect_uri that starts with our domain is safe" — prefix/substring matching instead of exact match.)redirect_uri values and mismatched state; you may only test systems you own or are explicitly authorized to test.)The key structures are the token header and the OAuth authorization request. Both are lab-safe illustrations with placeholder values.
Sending a bearer token (correct: header, not URL):
GET /api/v1/orders HTTP/1.1
Host: lab.localhost
Authorization: Bearer <access-token-placeholder>
Accept: application/json
An OAuth 2.0 Authorization Code request (the browser is redirected here). Note the four parameters that matter for security:
GET https://auth.lab.localhost/authorize
?response_type=code # ask for a code, NOT a token (avoids implicit flow)
&client_id=lab-client # who is asking
&redirect_uri=https://app.lab.localhost/callback # MUST exactly match a registered URI
&scope=orders:read # least privilege — only what is needed
&state=9f2c...random...a17 # random, bound to the user's session (anti-CSRF)
Decode vs. verify — the distinction to remember:
base64url_decode(payload) -> readable JSON (anyone can do this; proves NOTHING)
verify(header.payload, key, alg) AND check iss/aud/exp -> trust (server only)
Decoding tells you what a token claims. Only verification tells you whether to believe it.
APIs authenticate with tokens rather than session cookies. A token is a secret credential sent with each request. This shift changes the ways things can fail.
Authorization: Bearer ... header. (A JWT, or JSON Web Token, is a signed, self-contained token. JWT-specific flaws — alg:none, weak secrets, algorithm confusion — are covered in the Web App Security track and apply here directly.)Referer header), stored in client-side storage exposed to XSS, printed in error messages, or committed to repositories.OAuth lets a user grant an app limited access to their data on another service without sharing their password. It does this through an authorization flow that returns an access token, and often a refresh token used to obtain new access tokens.
OAuth is authorization delegation, but it is frequently misused for authentication. Common issues are:
redirect_uri validation, which allows token theft via an open redirect.state, which leaves the callback open to CSRF.redirect_uri allowlist.state parameter on flows.The example below shows the single most common OAuth token-theft primitive — a weak redirect_uri check — then the secure fix, then tests that prove the fix rejects bad input and accepts good input. It is written in Python (standard library only) so it runs anywhere without a framework, but the logic maps directly to any language.
# WARNING: intentionally vulnerable - use only in a local, isolated, authorized lab. Do not deploy.
#
# The bug: the authorization server accepts any redirect_uri that merely
# STARTS WITH the registered prefix. An attacker registers a lookalike.
REGISTERED = "https://app.lab.localhost/callback"
def is_allowed_redirect_insecure(redirect_uri: str) -> bool:
# BAD: prefix / substring matching
return redirect_uri.startswith("https://app.lab.localhost")
# An attacker-controlled callback that still passes the weak check:
attacker = "https://app.lab.localhost.evil.example/callback" # different host!
print(is_allowed_redirect_insecure(attacker)) # -> True (token would be sent to the attacker)
The string https://app.lab.localhost.evil.example/callback starts with the trusted prefix, but its real host is app.lab.localhost.evil.example — an attacker domain. The authorization server would happily redirect the code/token there.
# Secure: exact-match against an allowlist of fully-registered redirect URIs,
# compared on parsed components (scheme + host + path), not raw string prefixes.
from urllib.parse import urlsplit
# Exact URIs registered for this client. No wildcards, no prefixes.
ALLOWLIST = {
"https://app.lab.localhost/callback",
}
def _canonical(uri: str):
parts = urlsplit(uri)
# Normalize: lowercase scheme/host, keep path exact, reject anything odd.
return (parts.scheme.lower(), parts.hostname or "", parts.port, parts.path)
def is_allowed_redirect(redirect_uri: str) -> bool:
if redirect_uri not in ALLOWLIST: # exact string match first
return False
# Defensive re-parse so a normalization trick cannot slip past the set check.
want = {_canonical(u) for u in ALLOWLIST}
return _canonical(redirect_uri) in want
def test_redirect_validation():
good = "https://app.lab.localhost/callback"
bad_inputs = [
"https://app.lab.localhost.evil.example/callback", # lookalike host
"https://app.lab.localhost/callback/../evil", # path trickery
"https://app.lab.localhost:8443/callback", # unexpected port
"http://app.lab.localhost/callback", # downgraded scheme
"https://app.lab.localhost/callback?next=//evil", # extra query
"https://attacker.example/callback", # wrong host
]
assert is_allowed_redirect(good) is True, "legit callback must be accepted"
for b in bad_inputs:
assert is_allowed_redirect(b) is False, f"must reject: {b}"
print("PASS: rejects all attacker callbacks, accepts the registered one")
if __name__ == "__main__":
test_redirect_validation()
Expected output: the insecure snippet prints True (demonstrating the flaw), and the test prints PASS: rejects all attacker callbacks, accepts the registered one. The lesson of the VERIFY step is that a fix is only trustworthy once you have shown it both blocks the malicious inputs and still allows the legitimate one — a validator that rejects everything is not secure, just broken.
Walking the secure validator and its test:
ALLOWLIST = {"https://app.lab.localhost/callback"} — the only URIs this client may receive a code at. A set of exact strings; no wildcards, no prefixes. This is the entire security boundary._canonical(uri) uses urlsplit to break the URI into (scheme, host, port, path). Comparing parsed components, not raw text, defeats tricks like a trailing path or an unexpected port that look similar as strings.is_allowed_redirect first does an exact set membership test (redirect_uri not in ALLOWLIST). This alone rejects the lookalike host, the wrong host, the downgraded scheme, and the extra-query variants, because none of them equal the registered string.good is the registered callback and must return True. Each entry in bad_inputs is a real-world bypass attempt.Trace of the key inputs through the validator:
| Input | In ALLOWLIST? | Result | Why |
|---|---|---|---|
https://app.lab.localhost/callback |
yes | accept | exact registered URI |
https://app.lab.localhost.evil.example/callback |
no | reject | host is a different domain |
http://app.lab.localhost/callback |
no | reject | scheme downgraded to http |
https://app.lab.localhost:8443/callback |
no | reject | unexpected port |
https://app.lab.localhost/callback?next=//evil |
no | reject | extra query changes the string |
Contrast with the insecure startswith check: the lookalike host passes because the string begins with the trusted prefix even though the host is attacker-controlled. That single difference — comparing hosts vs. comparing string prefixes — is the whole vulnerability.
Real mistakes seen in production and in assessments:
1. Prefix/substring redirect_uri matching.
redirect_uri.startswith("https://app.example").https://app.example.attacker.com and https://app.example/../evil both pass; the code/token is sent to the attacker.startswith, contains, RegExp, or wildcard redirect config; write a test with a lookalike host.2. Treating a decoded JWT as verified.
"role":"admin", trust it.iss, aud, exp.verify, not decode; pin the allowed algorithm; reject alg:none.3. Putting tokens in the URL.
GET /api/orders?access_token=....Referer header sent to third parties.Authorization: Bearer <token> in a header, over TLS only.token= in query strings.4. Omitting or not verifying state.
state, or ignore it on return.state, store it bound to the session, and require an exact match on the callback.state is present and checked; a state you generate but never compare is no protection.5. Over-scoped, long-lived tokens.
scope=* and no expiry "to keep it simple."exp and scope claims; ask "what can this token do, and for how long?"When token or OAuth handling misbehaves, work the lifecycle stage by stage.
Authorization: Bearer <token> (capital B, one space). Confirm the token has not expired (exp) and that you are hitting the right aud/host.alg:none and mismatched algorithms.redirect_uri_mismatch. The URI sent must exactly equal a registered one — down to scheme, trailing slash, and port. Print both the sent and registered values and diff them character by character.state mismatch on callback. Confirm the same state you generated is stored server-side (or in a signed cookie) and compared on return; a fresh page load or a second tab can desync it.exp/nbf need synced clocks).Questions to ask when it fails: Which lifecycle stage broke — issue, transmit, store, validate, expire, or revoke? Is the token being verified or merely decoded? Does the redirect_uri match exactly? Is state both generated and checked? Is the token where it should never be — a URL, a log line, a repo?
Security & safety: detection and logging for token abuse.
Good logging is how you catch a stolen or forged token in production. The goal is to record enough to investigate an incident without ever turning your logs into the next leak.
Log (per auth-relevant event):
redirect_uri_rejected, signature_invalid, token_expired, scope_insufficient).jti) or a truncated hash prefix, the scope, and exp — never the token itself.client_id, requested vs. granted scope, and rejected redirect_uri/state values.Never log: the raw access/refresh token, the authorization code, passwords, session cookies, client secrets, private keys, or full JWTs. Redact PII you do not need. If you must correlate on a token, store a salted hash, not the value.
Events that signal abuse: a spike in signature_invalid or alg mismatches (forgery attempts); repeated redirect_uri_rejected with lookalike hosts (redirect attack); state mismatches (callback tampering); one token used from many IPs or geographies at once (token replay/theft); use of a scope the client never requested.
False positives arise from legitimate causes: users behind rotating mobile IPs or corporate proxies (looks like multi-IP use), clock skew making valid tokens look expired (exp/nbf), an app that legitimately refreshes tokens rapidly, and load balancers that hide the real client IP. Tune thresholds and enrich with the correlation id before alerting so a real operational quirk is not mistaken for an attack — and so a real attack is not lost in the noise.
Authorized use case. A team runs a scheduled API assessment of their own mobile-app backend in a staging environment. They enumerate every endpoint, confirm tokens travel only in headers over TLS, decode sample JWTs to read the claims, then verify signatures and check that expired or wrong-aud tokens are rejected. They test the OAuth callback with lookalike redirect_uri values and a missing state, all against systems they own — then file remediations with verification steps.
Professional best-practice habits
redirect_uri allowlists; verify JWT signature, iss, aud, and exp; reject alg:none and unexpected algorithms.scope and enforce it on the resource server, not just at issuance.| Beginner | Advanced | |
|---|---|---|
| Tokens | Read/decode a JWT, send it in the correct header | Verify signature, iss/aud/exp; catch alg confusion and weak secrets |
| OAuth | Recognize the Authorization Code flow parameters | Test redirect_uri/state/scope handling and PKCE; write remediations |
| Detection | Know which events to log | Build abuse-detection rules and tune false positives |
Always remember: passing an automated scanner does not prove an API is secure, and no system is ever "completely secure" — the goal is to raise cost and shrink the attack surface with verified controls.
All tasks are lab-only: run them against services you own or an intentionally-vulnerable app on localhost / a container / a CTF. Authorization checklist before any test: (1) you own the target or have explicit written permission; (2) it is an isolated lab (localhost/VM/container), not production or a third party; (3) you know the reset/cleanup steps; (4) you will stop if you hit anything out of scope. Every task ends by remediating and verifying, not just finding.
Beginner 1 — Decode vs. verify.
alg header; try flipping RS256/HS256 in your notes. Concepts: encoding vs. signature, iss/aud/exp.verify, not decode.Beginner 2 — Find tokens in the wrong place.
token= in URLs and any token committed to the repo.Referer header behavior. Concepts: leakage vectors, header-only transmission.Authorization header and confirm the log no longer contains it.Intermediate 1 — Harden redirect_uri validation.
startswith/substring/regex, rewrite it to exact-match on parsed scheme+host+port+path; keep a registered URI working.Intermediate 2 — Prove state stops callback CSRF.
state parameter in a lab OAuth flow.state, bind it to the session, and reject callbacks whose state does not match.state is rejected; the legitimate flow succeeds.state you generate but never compare is no defense. Concepts: anti-CSRF, session binding.state_mismatch and confirm it appears only on the forged attempt.Challenge — Build a token-abuse detection rule.
jti) used from many IPs/geos in a short window, plus spikes in signature_invalid and redirect_uri_rejected; write down at least two false-positive sources and how you would tune them.iss/aud/exp. Reject alg:none.redirect_uri (fix: exact-match allowlist), missing/unchecked state (fix: random, session-bound, verified), the implicit flow (fix: Authorization Code + PKCE), and over-broad scope (fix: least privilege).Authorization: Bearer <token> in a header (never a URL); build allowlists that compare parsed scheme+host+port+path, not string prefixes.state, and over-scoped/immortal tokens.