Ashok G

Draft

JWTs, access tokens, sessions, OAuth and Zero Trust

Almost every system that has users ends up answering the same question thousands of times a second: who is making this request, and are they allowed to?

The vocabulary around that question is badly tangled. A JWT is a format, an access token is a role, OAuth is a delegation framework, OpenID Connect is a login protocol, a session is server-side state, and Zero Trust is an architecture. They get used as synonyms, and most token-related bugs start there.

This article takes them apart one at a time, from the bytes of a JWT to how a request is evaluated in a Zero Trust network. The code runs with the Python standard library and OpenSSL.

The vocabulary

TermWhat it isNot to be confused with
Authentication (authn)Proving who you areAuthorisation
Authorisation (authz)Deciding what you may doAuthentication
CredentialA long-term secret or key: password, passkey, private keyA token
TokenA short-lived artefact issued after a credential is checkedA credential
SessionA continuing authenticated relationshipA cookie
CookieA browser storage and transport mechanismA session
JWTA signed or encrypted data formatAn access token
Access tokenA role: permission presented to an APIA JWT, which is only one possible format
Refresh tokenA role: permission to get new access tokensAn access token
ID tokenA role: a statement to a client that a user logged inAn access token
OAuth 2.0Delegated authorisationLogin
OpenID ConnectAn identity layer on OAuthOAuth
Zero TrustAn architecture with no implicit trust from locationA product

Two distinctions carry most of the weight.

Format versus role. A token’s format is how it is encoded. Its role is what it is used for. A JWT can be an ID token, an access token, a logout token, or a client assertion. An access token can be a JWT or a random string.

Bearer versus sender-constrained. A bearer token works for whoever holds it, like cash. A sender-constrained token also requires proof of possession of a key, like a card with a PIN.

Why tokens exist at all

Consider the simplest possible scheme, sending a password on every request. It has several problems:

  • The password is exposed at every service that receives it.
  • Every service has to verify it, so each needs access to credentials.
  • It cannot be limited: the password grants everything the user can do.
  • Revoking access means changing the password everywhere.
  • A slow password hash on every request is expensive by design.

Tokens solve this by exchanging one strong check for a narrower, shorter-lived artefact. A good token has limits a password does not: an expiry, an audience, and a scope.

There are two basic designs, and everything else is a variation of them.

Reference tokenSelf-contained token
ContainsA random identifierThe claims themselves, signed
Verified byLooking it upChecking a signature
RevocationImmediateHard
Network call on each useYes, or a cacheNo
SizeSmallLarger
Readable by the holderNoYes, unless encrypted
ExamplesSession ID, opaque access tokenJWT, PASETO, Kerberos ticket

This is one trade-off that appears again and again in this article: a stored lookup gives you control, and a signed statement gives you independence. You cannot fully have both.

Server-side sessions

A classic web session is a reference token in a cookie.

1. POST /login (password, then MFA)
2. Server creates a session row
3. Set-Cookie: __Host-sid=<random>; Secure; HttpOnly; SameSite=Lax; Path=/
4. Browser sends the cookie with every request
5. Server looks it up, and finds the user

The session table

In PostgreSQL, a session store needs little more than this:

CREATE TABLE sessions (
    id_hash       bytea PRIMARY KEY,          -- SHA-256 of the session ID
    user_id       bigint NOT NULL REFERENCES users (id),
    created_at    timestamptz NOT NULL DEFAULT now(),
    last_seen_at  timestamptz NOT NULL DEFAULT now(),
    auth_level    smallint NOT NULL,          -- how strongly they logged in
    auth_time     timestamptz NOT NULL,       -- when they last proved it
    device        text,
    revoked_at    timestamptz
);

CREATE INDEX ON sessions (user_id) WHERE revoked_at IS NULL;

-- On each request
SELECT user_id, auth_level, auth_time
FROM sessions
WHERE id_hash = sha256($1)
  AND revoked_at IS NULL
  AND last_seen_at > now() - interval '1 hour'
  AND created_at   > now() - interval '24 hours';

-- Log out everywhere
UPDATE sessions SET revoked_at = now()
WHERE user_id = $1 AND revoked_at IS NULL;

Two details are easy to miss.

Store a hash, not the ID. If the table leaks through a backup or a SQL injection, raw IDs are immediately usable as logins. Hashes are not. A fast hash is correct here, unlike with passwords, since the ID is already high-entropy random data.

Use a CSPRNG. NIST SP 800-63B and OWASP put the floor at 64 bits of entropy. In practice, use 256 bits from secrets.token_urlsafe(32) or an equivalent. A counter or a timestamp-based ID is guessable.

Timeouts

There are two independent timers:

  • Idle timeout ends a session that has gone quiet.
  • Absolute timeout ends a session regardless of activity, which limits a stolen session that an attacker keeps alive.

NIST SP 800-63B-4 gives these as recommendations:

Assurance levelOverall reauthenticationInactivity
AAL2Up to 24 hoursUp to 1 hour
AAL3Up to 12 hoursUp to 15 minutes

Banking, admin consoles, and similar systems often use much shorter limits.

AttributeEffect
SecureOnly sent over HTTPS
HttpOnlyNot readable from JavaScript
SameSite=LaxNot sent on most cross-site requests
SameSite=StrictNot sent on any cross-site request
SameSite=NoneSent cross-site, and needs Secure
DomainWidens scope to subdomains, usually best left unset
PathNarrows the path, and is not a security boundary
Max-AgeLifetime. Without it, a session cookie

The __Host- name prefix is a useful hardening step. A browser accepts such a cookie only if it is Secure, has Path=/, and has no Domain. That stops a compromised sibling subdomain from planting it.

Session attacks

Theft. XSS cannot read an HttpOnly cookie, but it can make requests from inside the page. Malware can steal cookies from disk. Support files can leak them too: in the 2023 Okta support system breach, attackers took session tokens from uploaded HAR files. A HAR is a browser network log, and it includes cookies and headers.

Phishing proxies. An adversary-in-the-middle page relays the real login, including MFA, and captures the resulting cookie. Phishing-resistant authenticators such as passkeys stop this at login, because the credential is bound to the real origin. They do not protect a session that was stolen later.

Fixation. An attacker gets a victim to log in using a session ID that the attacker already knows. The defence is to issue a new session ID at login and at each privilege change.

CSRF. A cross-site request carries the victim’s cookie automatically. Defences are SameSite, anti-CSRF tokens, and checking Origin or Sec-Fetch-Site headers. CSRF exists because the cookie is sent automatically, so a token sent by hand in an Authorization header is not vulnerable to it.

Subdomain takeover. A cookie with Domain=example.com goes to every subdomain, including forgotten ones.

Sessions at scale

A session store is a dependency on every request. With a database behind it, the usual pattern is a cache in front. It has good properties:

  • Revocation is immediate, or as fast as the cache allows.
  • You can list a user’s devices and end one of them.
  • Changing a user’s role takes effect at once.
  • A session can hold server-side data with no size limit.

It does not suit every architecture. Each service has to reach the store, which is awkward across organisations and regions. That is the gap self-contained tokens fill.

JWT: the format

A JSON Web Token, RFC 7519, is a set of claims in a compact form. It is built on a family of standards:

RFCNamePurpose
7515JWSSigned data
7516JWEEncrypted data
7517JWKKeys as JSON
7518JWAThe algorithms
7519JWTClaims, on top of JWS or JWE
8725JWT BCPHow to use it safely

The usual form is a JWS with three base64url parts, joined by dots:

header.payload.signature

Building one by hand

This program creates and verifies an HS256 JWT with nothing but the standard library, then tries to break it.

import base64, hashlib, hmac, json, secrets

def b64url(data):
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

def unb64url(text):
    return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))

def enc(obj):
    return b64url(json.dumps(obj, separators=(",", ":")).encode())

def sign(claims, key):
    head_body = enc({"alg": "HS256", "typ": "JWT"}) + "." + enc(claims)
    tag = hmac.new(key, head_body.encode(), hashlib.sha256).digest()
    return head_body + "." + b64url(tag)

def verify(token, key, issuer, audience, now, leeway=30):
    head, body, tag = token.split(".")
    if json.loads(unb64url(head)).get("alg") != "HS256":     # pinned by the verifier
        raise ValueError("unexpected alg")
    want = hmac.new(key, f"{head}.{body}".encode(), hashlib.sha256).digest()
    if not hmac.compare_digest(want, unb64url(tag)):         # constant time
        raise ValueError("bad signature")
    claims = json.loads(unb64url(body))                      # only now trust the body
    if claims["iss"] != issuer:
        raise ValueError("wrong issuer")
    aud = claims["aud"]
    if audience not in ([aud] if isinstance(aud, str) else aud):
        raise ValueError("wrong audience")
    if now >= claims["exp"] + leeway:
        raise ValueError("expired")
    return claims

key = b"k" * 32
now = 1793500000
claims = {"iss": "https://id.example", "sub": "user-42", "aud": "api://orders",
          "exp": now + 300, "iat": now, "jti": "a1b2c3", "scope": "orders:read"}
token = sign(claims, key)
print(token)
print(len(token), "bytes")
head, body, tag = token.split(".")
print(unb64url(head))
print(unb64url(body))
print("ok:", verify(token, key, "https://id.example", "api://orders", now)["sub"])

changed = json.loads(unb64url(body))
changed["scope"] = "orders:admin"
forged = f"{head}.{enc(changed)}.{tag}"
none = f"{enc({'alg': 'none'})}.{body}."

attempts = [
    ("tampered payload", forged, key, "api://orders", now),
    ("alg none", none, key, "api://orders", now),
    ("wrong audience", token, key, "api://billing", now),
    ("expired", token, key, "api://orders", now + 331),
    ("wrong key", token, b"x" * 32, "api://orders", now),
]
for name, candidate, k, aud, when in attempts:
    try:
        verify(candidate, k, "https://id.example", aud, when)
        print(name, "-> ACCEPTED")
    except ValueError as e:
        print(f"{name} -> rejected: {e}")

big = dict(claims, groups=[f"group-{i}" for i in range(50)])
print("opaque token:", len(secrets.token_urlsafe(32)), "bytes")
print("jwt with 50 groups:", len(sign(big, key)), "bytes")

Output:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lkLmV4YW1wbGUiLCJzdWIiOiJ1c2VyLTQyIiwiYXVkIjoiYXBpOi8vb3JkZXJzIiwiZXhwIjoxNzkzNTAwMzAwLCJpYXQiOjE3OTM1MDAwMDAsImp0aSI6ImExYjJjMyIsInNjb3BlIjoib3JkZXJzOnJlYWQifQ.AD7miIJ9gqTrimlCGL2mzrQ0IYuX6XdowR7LcAqyjmg
263 bytes
b'{"alg":"HS256","typ":"JWT"}'
b'{"iss":"https://id.example","sub":"user-42","aud":"api://orders","exp":1793500300,"iat":1793500000,"jti":"a1b2c3","scope":"orders:read"}'
ok: user-42
tampered payload -> rejected: bad signature
alg none -> rejected: unexpected alg
wrong audience -> rejected: wrong audience
expired -> rejected: expired
wrong key -> rejected: bad signature
opaque token: 43 bytes
jwt with 50 groups: 997 bytes

Several things follow from those lines.

The payload is public. The middle part is encoded, not encrypted. Anyone holding the token can read it, so it should never contain anything the holder or a log reader should not see. Every eyJ at the start of a string is base64 of {", which is why JWTs are easy to find in logs.

The signature covers the encoded bytes, not the JSON meaning. A change to any byte breaks it.

Size grows quickly. Adding fifty groups took this token to nearly 1 KB, and RS256 adds about 340 characters of signature alone. These go in a header on every request, and many servers and proxies cap total header size at around 8 KB.

Registered claims

ClaimMeaning
issIssuer, who created this
subSubject, who it is about, unique within the issuer
audAudience, who it is for
expExpiry time
nbfNot valid before
iatIssued at
jtiUnique ID, for replay detection and denylists

Times are NumericDate: seconds since the Unix epoch.

iss plus sub together identify a principal. sub alone does not, and two issuers can use the same value. Treating an email address as the identity is a recurring error: emails change, get reassigned, and may not be verified.

Common non-registered claims include:

ClaimFromMeaning
scopeRFC 8693, RFC 9068Space-separated scopes
client_idRFC 9068Which client requested it
azpOIDCAuthorised party
auth_timeOIDCWhen the user last authenticated
acrOIDCAssurance level of the login
amrOIDCMethods used, such as pwd or mfa
nonceOIDCBinds an ID token to a request
cnfRFC 7800The key this token is bound to
actRFC 8693Who is acting on behalf of the subject

Header parameters

HeaderMeaningRisk
algSignature algorithmAttacker-controlled, must be pinned
typToken typeUsed to stop cross-use
kidWhich key signed thisAttacker-controlled lookup key
jkuURL of a key setSSRF and forged keys if followed
jwkThe key itself, embeddedForgery if trusted
x5u, x5cCertificatesSame class of risk
critExtensions that must be understoodIgnored at your peril

The header is unauthenticated until the signature has been verified, yet it is what tells a naive verifier how to verify. Every JWT attack in the next section exploits that circle.

Signing algorithms

algTypeKeySignatureNotes
HS256HMACShared secret32 bytesVerifier can also forge
RS256RSA PKCS#1 v1.5RSA256 bytes at 2048 bitsMost widely supported
PS256RSA-PSSRSA256 bytes at 2048 bitsBetter padding
ES256ECDSA P-256EC64 bytesCompact, needs good randomness or a deterministic nonce
EdDSAEd25519Ed2551964 bytesSimple and hard to misuse
noneNoneNoneEmptyNever accept

The HS256 line needs emphasis. With a shared secret, anyone who can verify can also forge. It is fine when issuer and verifier are the same service. It is wrong when many APIs verify tokens, since compromise of any one lets it mint tokens for all.

A shared secret also has to be strong. HS256 tokens can be attacked offline, so a captured token can be fed to a cracking tool against a wordlist. A secret should be random and at least 256 bits.

Rough costs on one laptop, using openssl speed for the asymmetric algorithms:

OperationPer second
RSA-2048 sign2,457
RSA-2048 verify94,116
ECDSA P-256 sign81,993
ECDSA P-256 verify27,576

Verification is fast enough that it is rarely the limit. RSA favours systems that verify far more than they sign, which fits tokens well.

Encrypted JWTs

A JWE has five parts instead of three:

header.encrypted_key.iv.ciphertext.tag

JWE keeps claims secret from the client and from logs. It is less common, because a simpler option exists: if the client should not read the claims, use an opaque token and put them on the server. When used, it is usually nested: signed first, then encrypted, so the claims are authenticated by the issuer.

  • PASETO removes algorithm choice from the token: a version implies a fixed suite, so there is no alg to attack.
  • SD-JWT, RFC 9901, lets a holder reveal chosen claims, which suits digital credentials.
  • CBOR Web Tokens, RFC 8392, use a compact binary form for constrained devices.
  • SAML assertions are XML predecessors, still common in enterprise single sign-on.
  • Macaroons allow a holder to add restrictions to a token, without talking to the issuer.

How JWTs get broken

alg: none

The JWS standard defines an “unsecured” algorithm with an empty signature. In 2015, Tim McLean showed that several libraries honoured it, so stripping a signature produced a valid token. RFC 9068 forbids none for access tokens outright.

The naive form is short:

def naive(token, key):
    head, body, tag = token.split(".")
    if json.loads(unb64url(head))["alg"] == "none":
        return json.loads(unb64url(body))        # accepted with no signature

Algorithm confusion

This is the most instructive JWT attack. A service expects RS256, and verifies with an RSA public key. The public key is, by definition, public. If the verifier reads alg from the token, an attacker can do this:

  1. Take the server’s public key PEM file.
  2. Set the header to HS256.
  3. Compute an HMAC over the token, using the public key’s bytes as the secret.

A library with one verify(token, key) function now runs HMAC with the same key bytes, gets the same result, and accepts the token. This script runs the attack against real RSA keys. Generate them first:

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out rsa.key
openssl pkey -in rsa.key -pubout -out rsa.pub
import base64, hashlib, hmac, json, os, subprocess, tempfile

def b64url(d): return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
def unb64url(t): return base64.urlsafe_b64decode(t + "=" * (-len(t) % 4))
def enc(o): return b64url(json.dumps(o, separators=(",", ":")).encode())

def rsa_sign(msg):
    return subprocess.run(["openssl", "dgst", "-sha256", "-sign", "rsa.key"],
                          input=msg, capture_output=True, check=True).stdout

def rsa_verify(msg, sig):
    with tempfile.NamedTemporaryFile(delete=False) as f:
        f.write(sig)
    r = subprocess.run(["openssl", "dgst", "-sha256", "-verify", "rsa.pub", "-signature", f.name],
                       input=msg, capture_output=True)
    os.unlink(f.name)
    return r.returncode == 0

def bad_verify(token, key):
    """Trusts the alg in the header: the classic mistake."""
    head, body, sig = token.split(".")
    msg, sig = f"{head}.{body}".encode(), unb64url(sig)
    alg = json.loads(unb64url(head))["alg"]
    if alg == "RS256":
        return rsa_verify(msg, sig)
    if alg == "HS256":
        return hmac.compare_digest(hmac.new(key, msg, hashlib.sha256).digest(), sig)
    return False

def good_verify(token):
    """The verifier decides the algorithm."""
    head, body, sig = token.split(".")
    if json.loads(unb64url(head))["alg"] != "RS256":
        return False
    return rsa_verify(f"{head}.{body}".encode(), unb64url(sig))

pem = open("rsa.pub", "rb").read()
msg = f'{enc({"alg": "RS256", "typ": "at+jwt"})}.{enc({"sub": "user-42"})}'
token = msg + "." + b64url(rsa_sign(msg.encode()))
print("legit:  bad_verify", bad_verify(token, pem), "| good_verify", good_verify(token))

# The attacker has only the public key
msg = f'{enc({"alg": "HS256", "typ": "JWT"})}.{enc({"sub": "admin"})}'
forged = msg + "." + b64url(hmac.new(pem, msg.encode(), hashlib.sha256).digest())
print("forged: bad_verify", bad_verify(forged, pem), "| good_verify", good_verify(forged))

Output:

legit:  bad_verify True | good_verify True
forged: bad_verify True | good_verify False

An attacker with no secret became admin. The fix is a one-line principle: the verifier decides the algorithm, the token does not. Good libraries require an explicit allow-list, and bind each key to one algorithm.

Key injection through headers

  • Embedded jwk. If a verifier trusts a key inside the token, an attacker signs with their own key and attaches it. CVE-2018-0114 in a Node library was this bug.
  • jku or x5u. If a verifier fetches a key set from a URL in the token, the attacker supplies the URL. It is also a server-side request forgery route.
  • kid injection. kid is a string the attacker controls. If it reaches a file path or a SQL query, it can cause path traversal or injection. For example, a kid pointing to a file with known contents lets the attacker sign with that as an HMAC key.

Treat these headers as hostile. Look up kid in a fixed set of trusted keys, and take the key set URL from configuration.

Psychic signatures

CVE-2022-21449 was a bug in Java 15 to 18, in the rewritten ECDSA code. Verification divides by a value derived from s, and ECDSA requires r and s to be non-zero. The check was missing, so a signature of r = 0, s = 0 verified for any message under any key. It affected ES256 JWTs, SAML, and WebAuthn messages. Well-designed protocols can still fail in their library.

Signature exclusion and stripping

Some libraries have a decode function that does not verify, and a verify function that does. Code that calls the wrong one parses forged tokens quite happily. API gateway rules that check for a header’s presence but not its validity make the same mistake.

Weak secrets

If alg is HS256, a captured token allows offline guessing. Default secrets from tutorials, such as secret or the sample in a framework’s documentation, have turned up in production.

Confused audience and token reuse

A valid token used in the wrong place is the most common real failure:

  • An ID token accepted by an API as an access token.
  • A token for API A replayed to API B, because neither checks aud.
  • A token from a multi-tenant issuer accepted from any tenant, because iss or a tenant claim was not validated.
  • A token type confused with another, because all use the same key. This is why RFC 8725 recommends explicit typing, and RFC 9068 defines typ: at+jwt.

Stolen signing keys

If the signing key leaks, an attacker can create tokens for anyone, with no login. In the 2023 Storm-0558 intrusion, an actor used a stolen Microsoft consumer signing key to forge tokens that were accepted for enterprise mail accounts, partly because of a validation flaw that let a key from one system work in another. It is the worst case for self-contained tokens: nothing is compromised in any logged sense, because the forged tokens are cryptographically valid.

This is why signing keys belong in HSMs or cloud KMS, are rotated, and are separated by purpose.

JWE-specific attacks

Decompression bombs through the zip header, and invalid curve attacks on elliptic key agreement, are classes that apply to encrypted tokens. RFC 8725 covers them, and its successor, a draft known as 8725bis, adds attacks discovered since.

The verification checklist

A correct verifier does these in order:

  1. Check that the token has the expected structure and size.
  2. Check alg against an allow-list fixed by configuration.
  3. Check typ where the token type is defined.
  4. Select the key from a trusted set, by kid.
  5. Verify the signature.
  6. Check iss exactly.
  7. Check aud contains this service.
  8. Check exp, nbf, and iat with a small leeway.
  9. Check the scope or other permission for this action.
  10. Check the sender constraint, if there is a cnf claim.
  11. Check revocation, if the risk needs it.

Leeway should be small, seconds rather than minutes. It exists for clock skew, and every second of it extends a token’s life.

Key distribution: JWKS

Verifiers need public keys. An issuer publishes them as a JSON Web Key Set, at a URL that is found through a discovery document.

curl https://accounts.google.com/.well-known/openid-configuration
{
  "keys": [{
    "kty": "RSA",
    "use": "sig",
    "alg": "RS256",
    "kid": "key-2026-10",
    "n": "...",
    "e": "AQAB"
  }]
}

Rotation depends on overlap:

Day 0    Publish new key B alongside A. Keep signing with A.
Day 2    All verifier caches have refreshed. Start signing with B.
Day 3    The last tokens signed by A have expired.
Day 4    Remove A from the key set.

For an emergency, you skip the overlap, remove the key, and accept that some valid tokens fail.

Verifiers cache the key set. A sensible client refreshes on a schedule, and on an unknown kid, with a rate limit, since attackers can send random kid values to force fetches.

Access tokens, refresh tokens and ID tokens

These three are roles.

Access tokenRefresh tokenID token
AudienceThe APIThe authorisation serverThe client
PurposeCall an APIGet new tokensTell the client who logged in
LifetimeMinutesDays to monthsMinutes
FormatOpaque or JWTUsually opaqueAlways JWT
Client reads it?Should notShould notYes
Sent to APIs?YesNeverNever

An access token is opaque to the client. A client that parses one breaks when the issuer changes its format. The client’s job is to carry it to the API.

An ID token is never sent to an API. Its aud is the client, so a correct API rejects it.

A refresh token is sent only to the authorisation server, which is what makes short access token lifetimes bearable. Users stay logged in for weeks, while the token exposed to APIs lives for minutes.

Opaque or JWT access tokens?

ConsiderationOpaqueJWT
LatencyNeeds introspection, or a cacheLocal check
Issuer outageAPIs fail, unless cachedAPIs continue
Instant revocationYesNeeds extra work
Claims visible to the clientNoYes
SizeTinyHundreds of bytes or more
Works across organisationsNeeds issuer accessNeeds only public keys

A common hybrid is phantom tokens: the client holds an opaque token, and the API gateway exchanges it for a JWT, which is then passed inside the network. Clients see nothing, and internal services get signed claims.

Revocation strategies for JWTs

A JWT cannot be recalled, but there are several options, each with a cost.

StrategyTime to take effectCost
Short expiryUp to the token lifetimeMore refreshes
Denylist by jtiSecondsState at each verifier
Per-user “valid after” timestampSecondsA lookup, but small state
Rotating the signing keyImmediateInvalidates everyone
Introspection on sensitive routesImmediateA network call on those routes
Sender constraintPrevents use of theftClient key management
Shared Signals eventsSecondsInfrastructure

A denylist reintroduces state, which leads to a fair question: if every API has to check a shared list, what did the signature buy? The answer is that the list holds only revoked tokens, not all live ones, and entries can be dropped when the token would have expired anyway.

The default many systems settle on is a 5 to 15 minute access token, a rotated refresh token, and introspection for high-risk actions.

OAuth 2.0

OAuth solves a specific problem. A photo printing site wants to read your photos from another service. Before OAuth, it would ask for your password. That gave it full access, forever, and the service could not tell it apart from you.

OAuth lets you grant a limited token instead.

The four roles

RoleExample
Resource ownerYou
ClientThe printing site
Authorisation server (AS)The login and consent service
Resource server (RS)The photo API

Authorization code flow with PKCE

This is the flow to use for nearly every case with a user.

Browser           Client             Auth server          API
   |                 |                    |                |
   |  1. click login |                    |                |
   |---------------->|                    |                |
   |  2. redirect    |                    |                |
   |<----------------|                    |                |
   |  3. /authorize + code_challenge      |                |
   |------------------------------------->|                |
   |  4. log in, MFA, consent             |                |
   |<------------------------------------>|                |
   |  5. redirect with ?code=xyz          |                |
   |<-------------------------------------|                |
   |  6. deliver code                     |                |
   |---------------->|                    |                |
   |                 | 7. POST /token     |                |
   |                 |    code + verifier |                |
   |                 |------------------->|                |
   |                 | 8. access, refresh |                |
   |                 |<-------------------|                |
   |                 | 9. API call        |                |
   |                 |------------------------------------>|

The authorisation request:

GET /authorize?response_type=code
  &client_id=print-app
  &redirect_uri=https%3A%2F%2Fprint.example%2Fcb
  &scope=photos.read
  &state=af0ifjsldkj
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256 HTTP/1.1
Host: id.example

The code exchange happens over a direct back channel:

POST /token HTTP/1.1
Host: id.example
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fprint.example%2Fcb
&client_id=print-app
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

The response:

{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 300,
  "refresh_token": "8xLOxBtZp8",
  "scope": "photos.read"
}

Tokens never appear in the browser’s URL. The front channel carries only a short-lived, one-time code. It is delivered through a redirect, which can leak through logs, history, and referrers, so the code alone must be useless.

Why PKCE exists

A mobile app cannot keep a client secret, since anyone can extract it from the binary. So if another app on the device intercepts the code, it could exchange the code for tokens. PKCE, RFC 7636, closes that gap.

The client makes a random code_verifier, and sends its hash as the code_challenge. Later it reveals the verifier. An interceptor has the code, and has seen the challenge, but it cannot reverse the hash.

import base64, hashlib

verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=")
print(challenge.decode())
E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

That matches the worked example in RFC 7636’s Appendix B.

RFC 9700, the OAuth security best current practice from January 2025, says that authorisation servers must support PKCE and that public clients must use it, and its advice extends to all client types, since PKCE also stops code injection. S256 is the only acceptable method.

state and nonce

state stops CSRF on the redirect, by tying the response to the browser session that started it. A random value goes in a cookie or session, and is compared on return. PKCE provides similar protection, which RFC 9700 recognises, but state still carries application data.

nonce, in OpenID Connect, binds an ID token to the request, and stops replay of the ID token.

The grant types

GrantUseStatus
Authorization code with PKCEAny user-facing clientUse this
Client credentialsService with no userUse this
Refresh tokenRenewing accessUse this, with protection
Device authorization, RFC 8628TVs and CLIsUse this for input-limited devices
Token exchange, RFC 8693Service acting for a userUse this for delegation
JWT bearer, RFC 7523Signed assertion as a grantUse this for federation
ImplicitOld browser appsShould not be used
PasswordLegacyMust not be used

Implicit returned tokens directly in the redirect fragment. It existed because browsers could not make cross-origin POSTs at the time. CORS and PKCE removed that need, and the token in the URL is exposed. RFC 9700 says it should not be used, and OAuth 2.1 removes it.

Password gives the client the user’s password, which defeats the purpose of OAuth, and cannot work with MFA or passkeys. RFC 9700 says it must not be used.

Client credentials

POST /token HTTP/1.1
Authorization: Basic cHJpbnQtam9iOnNlY3JldA==

grant_type=client_credentials&scope=reports.write

No user is involved, so the token represents the service itself. A shared secret is the weakest way to authenticate. A better one is private_key_jwt, where the client signs a short-lived JWT, RFC 7523. mTLS, RFC 8705, is another. In both, no secret crosses the network.

Device authorization

For a TV or a CLI, the device shows a code, and the user approves it on a phone.

Device -> AS:  POST /device_authorization
AS -> Device:  device_code, user_code=WDJB-MJHT, verification_uri
Device shows:  Visit https://id.example/device and enter WDJB-MJHT
Device polls:  POST /token with device_code, until approved

It is exposed to phishing, since an attacker can start a flow, and persuade a victim to enter the code on the real site. The victim logs in to the real provider, and the attacker’s device gets the tokens. So it should be disabled where it is not needed.

Refresh token rotation

A refresh token is valuable and long-lived. RFC 9700 says that for public clients, it must be sender-constrained or rotated.

With rotation, each use returns a new token, and the old one becomes invalid. All tokens from one login are a family. If an already-used token appears, the server cannot tell who is the thief, so it revokes the family.

import secrets

class AuthServer:
    def __init__(self):
        self.families, self.tokens = {}, {}

    def issue(self):
        family = secrets.token_hex(4)
        self.families[family] = True
        return self._new(family)

    def _new(self, family):
        token = secrets.token_hex(8)
        self.tokens[token] = {"family": family, "used": False}
        return token

    def refresh(self, token):
        record = self.tokens.get(token)
        if record is None or not self.families[record["family"]]:
            return "invalid_grant"
        if record["used"]:
            self.families[record["family"]] = False
            return "invalid_grant, reuse detected, family revoked"
        record["used"] = True
        return self._new(record["family"])

auth = AuthServer()
first = auth.issue()
stolen = first
second = auth.refresh(first)
print("client rotates:", second != first)
print("attacker replays:", auth.refresh(stolen))
print("client next refresh:", auth.refresh(second))
client rotates: True
attacker replays: invalid_grant, reuse detected, family revoked
client next refresh: invalid_grant

The legitimate client is logged out too, which is the price of safety. If the attacker uses the stolen token first, the same thing happens in the other order: the real client’s next refresh triggers detection.

Rotation creates a practical race. A mobile app that loses the response on a bad network retries with the old token, and looks like an attacker. Many servers allow a brief grace period for this.

Scopes and their limits

A scope such as photos.read limits what a client may do on a user’s behalf. The user’s own permissions still apply. Effective access is the intersection:

Client's scope ∩ User's permissions ∩ Resource policy = allowed

Scopes are coarse. “Pay 50 euros to this account” does not fit in a string. Rich Authorization Requests, RFC 9396, add structure:

{
  "authorization_details": [{
    "type": "payment_initiation",
    "instructedAmount": { "currency": "EUR", "amount": "50.00" },
    "creditorAccount": { "iban": "DE02..." }
  }]
}

Scopes also say nothing about which object. An API that checks orders:read but not whether this order belongs to this user has a broken object-level authorisation flaw, which tops the OWASP API risks. A token gets you to the door, and the object check happens in the application.

Fine-grained authorisation

Tokens are a poor place for detailed permissions, since these change often, and tokens are large and immutable. Patterns to use instead:

ModelDecision based onExample
RBACRolesadmin
ABACAttributes of user, resource, contextDepartment and clearance
ReBACRelationships in a graphEditor of a folder containing this file
Policy as codeRules evaluated by an engineOPA, Cedar

Google’s Zanzibar paper set out the relationship model, and the OpenID AuthZEN work defines a standard API between enforcement and decision points.

Token exchange and delegation

A frontend API receives a user’s token, and needs to call a backend. Forwarding the token is a poor idea: its aud is wrong, and the backend now has the power to call anything else the token allows. RFC 8693 instead lets a service exchange it for a new token.

POST /token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=eyJ...
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&audience=api://inventory
&scope=stock.read

The result has a narrower audience and scope, and can record the chain:

{
  "iss": "https://id.example",
  "aud": "api://inventory",
  "sub": "user-42",
  "scope": "stock.read",
  "act": { "sub": "service:orders" }
}

The act claim distinguishes delegation, where a service acts for a user and both are visible, from impersonation, where the service simply becomes the user. Delegation is better for audit.

The confused deputy is what this prevents. A service with broad rights can be tricked into using those rights for a caller who lacks them. Passing the user’s identity down the chain means the final decision considers the user.

Other parts of the OAuth family

RFCNamePurpose
6749OAuth 2.0The framework
6750Bearer usageHow to send tokens
7009RevocationClient revokes a token
7521, 7523AssertionsJWT as a grant or client authentication
7591Dynamic registrationClients register themselves
7636PKCECode protection
7662IntrospectionRS asks whether a token is active
8252Native appsUse the system browser, and loopback or claimed URLs
8414AS metadataDiscovery
8628Device grantInput-limited devices
8693Token exchangeDelegation
8705mTLSClient auth and bound tokens
8707Resource indicatorsName the target API
9068JWT access tokensA standard profile
9101JARSigned authorisation requests
9126PARPush request parameters over a back channel
9207iss parameterDefends against mix-up attacks
9396RARStructured permissions
9449DPoPProof of possession
9470Step-upAsk for stronger authentication
9635GNAPA separate successor protocol
9700Security BCPCurrent best practice
9728Protected resource metadataAn API describes its AS

PAR is worth a note. Instead of carrying parameters through the browser, the client POSTs them to the server, and gets a request_uri to redirect with. That hides them from the browser, and authenticates them before the user arrives.

OAuth 2.1 consolidates the current best practice in one document: PKCE everywhere, no implicit or password grants, exact redirect URI matching, and no bearer tokens in query strings. As of draft 16, in September 2026, it is still an Internet-Draft.

FAPI 2.0 is a stricter OpenID Foundation profile, built for financial APIs. It requires PAR, PKCE, and sender-constrained tokens.

OAuth attacks

Open redirectors and loose redirect matching. If an AS allows a wildcard, the code can go to an attacker’s page. RFC 9700 requires exact string matching, with one exception for localhost ports in native apps.

Code injection. An attacker places a stolen code into their own session. PKCE stops this.

Mix-up. A client using several authorisation servers is tricked into sending a code to the wrong one. RFC 9207’s iss parameter defends against it.

Consent phishing. A malicious app asks for permission through the real provider’s real consent page. Everything is genuine, except the app. No credentials are stolen, MFA does not help, and the access persists after a password change.

Third-party token theft. In the 2025 Salesloft Drift incident, attackers used stolen OAuth tokens from an integration to pull data from customers’ Salesforce instances. A connected app’s long-lived token is a credential that security teams often do not watch.

Token leakage. Tokens in query strings go to logs, history, and referrers. RFC 9700 says access tokens must not be sent in a query parameter.

Scope escalation. A client requests more at refresh, or a server fails to enforce the original consent.

OpenID Connect

OAuth says what a client may do. It does not say who the user is. An access token proves that some API access was granted, not that a user is present at the client.

OpenID Connect adds identity to the code flow. When scope includes openid, the token response also contains an ID token:

{
  "iss": "https://id.example",
  "sub": "248289761001",
  "aud": "print-app",
  "exp": 1793500300,
  "iat": 1793500000,
  "auth_time": 1793499950,
  "nonce": "n-0S6_WzA2Mj",
  "acr": "urn:mace:incommon:iap:silver",
  "amr": ["pwd", "otp"]
}

The client validates it like any other JWT, including its own aud and the nonce. The client then normally creates its own session. This confuses people, because there are now several sessions:

1. The identity provider's session    (SSO)
2. Your application's session         (your cookie)
3. Access and refresh tokens          (for APIs)

Each has its own lifetime. Logging out of one does not end the others. OIDC defines RP-initiated, front-channel, and back-channel logout. Back-channel logout, where the provider POSTs a signed logout token to your server, is the reliable one, as browsers increasingly block third-party cookies in iframes.

SSO works because of the first of these: a second application redirects to the provider, which sees its own cookie, and signs the user in with no prompt.

Tokens in the browser

Where a single-page app keeps its tokens is among the most argued questions in web security.

StorageXSS can read itCSRF appliesSurvives reload
localStorageYesNoYes
sessionStorageYesNoTab only
JS memoryHarder, not impossibleNoNo
HttpOnly cookieNo, but can use itYesYes
Server-side, via BFFNoYes, at the BFFYes

The honest position is that with XSS, an attacker running in your origin can already make requests as the user. HttpOnly stops the token leaving the browser, and that matters, because a stolen token works later, from anywhere. Nothing in the browser stops abuse during the attack.

The IETF draft on OAuth for browser-based apps ranks three patterns:

  1. Backend for Frontend. A small server acts as a confidential OAuth client, and holds all tokens. The browser gets only an HttpOnly session cookie. This is the strongest option.
  2. Token-mediating backend. The backend runs the flow, then gives the access token to the browser.
  3. Browser-based client. The browser runs the flow itself. It has the most exposure.
Browser <-- cookie --> BFF <-- access token --> API
                        |
                        +-- refresh token stays here

Notice the circle: the most secure way to use tokens in a browser is a session cookie. Tokens are excellent between services, and for APIs called by many clients. A traditional web app with one backend may have no need of them at the browser.

“Should I use JWT for sessions?”

Often not. The common pattern of putting a JWT in a cookie, as the session, gives up a lot:

Claimed benefitReality
Stateless, so scalableMost apps hit a database on each request anyway
No session storeRevocation brings one back
Works across servicesOnly if audience and keys are managed well
SmallerIt is much bigger than an ID
More secureIt is not, and logout becomes weak

If an application has one backend and its own users, a server-side session in Postgres, with a cache in front if needed, is simpler and stronger. JWTs earn their place when independent services must verify identity without a shared store.

Sender-constrained tokens

A bearer token works for any holder. Sender constraints bind a token to a key, so a stolen token alone is useless.

Mutual TLS

RFC 8705 binds the token to the client certificate from the TLS handshake. The token holds a hash of it:

{ "cnf": { "x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2" } }

The resource server compares that hash with the certificate on the connection. This works well between services. It works poorly in browsers, and TLS termination at a proxy needs the certificate to be forwarded safely.

DPoP

RFC 9449 works at the application layer. The client creates a key pair, and signs a short proof for each request. This program builds a real proof with ES256. Generate a key first:

openssl ecparam -name prime256v1 -genkey -noout -out ec.key
openssl pkey -in ec.key -pubout -out ec.pub
import base64, hashlib, json, subprocess

def b64url(d): return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
def enc(o): return b64url(json.dumps(o, separators=(",", ":")).encode())

def der_to_raw(der):
    """OpenSSL returns DER. JOSE wants r and s as 32 raw bytes each."""
    rlen = der[3]
    r = der[4:4 + rlen]
    s = der[4 + rlen + 2:]
    return r.rjust(33, b"\0")[-32:] + s.rjust(33, b"\0")[-32:]

public = subprocess.run(["openssl", "pkey", "-pubin", "-in", "ec.pub", "-outform", "DER"],
                        capture_output=True).stdout[-64:]
jwk = {"crv": "P-256", "kty": "EC", "x": b64url(public[:32]), "y": b64url(public[32:])}

access_token = "2YotnFZFEjr1zCsicMWpAA"
header = enc({"typ": "dpop+jwt", "alg": "ES256", "jwk": jwk})
claims = enc({"jti": "e1j3V_bKic8-LAEB", "htm": "GET",
              "htu": "https://api.example/orders", "iat": 1793500000,
              "ath": b64url(hashlib.sha256(access_token.encode()).digest())})
message = f"{header}.{claims}".encode()
der = subprocess.run(["openssl", "dgst", "-sha256", "-sign", "ec.key"],
                     input=message, capture_output=True, check=True).stdout
raw = der_to_raw(der)
print("proof:", len(message) + 1 + len(b64url(raw)), "bytes,", len(raw), "byte signature")

thumbprint = b64url(hashlib.sha256(json.dumps(jwk, separators=(",", ":"), sort_keys=True).encode()).digest())
print("token claim:", json.dumps({"cnf": {"jkt": "..."}}), "with jkt of length", len(thumbprint))
proof: 498 bytes, 64 byte signature
token claim: {"cnf": {"jkt": "..."}} with jkt of length 43

Each part defends against something:

FieldDefends against
jwkTells the server which key, checked against the token’s cnf.jkt
htm, htuReuse of the proof on another method or URL
iat, jtiReplay of the proof later
athUse of the proof with another access token
Server noncePre-generated proofs

The request carries both:

GET /orders HTTP/1.1
Host: api.example
Authorization: DPoP 2YotnFZFEjr1zCsicMWpAA
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs...

DPoP works in browsers, with WebCrypto keys marked non-extractable. So XSS can use the key while it runs, but cannot take the key away. RFC 9449 is clear on the limit: an attacker running code in the client’s context defeats it.

Device-bound sessions

The same idea reaches cookies. Device Bound Session Credentials, a Chrome-led proposal, ties a session to a key in the device’s secure hardware, so a stolen cookie fails on another machine.

Workload identity

Half of the callers in a modern system are not people. A service with a long-lived API key in an environment variable is the machine version of a password on a sticky note.

The modern pattern has no stored secret. The platform attests what a workload is, and issues a short-lived identity.

SPIFFE and SPIRE

SPIFFE is a standard for naming workloads:

spiffe://example.org/ns/payments/sa/api

A workload obtains an SVID, its identity document, from a local Workload API. SPIRE is the main implementation, and it verifies the workload through attestation, such as its Kubernetes service account or its node. There are two forms:

  • X.509-SVID, a certificate used for mTLS. It proves possession, so it is preferred.
  • JWT-SVID, a signed token, for when mTLS is impractical. It is a bearer token.

Both are short-lived and rotated automatically.

Kubernetes service account tokens

Kubernetes projects a short-lived, audience-bound JWT into each pod:

{
  "aud": ["https://kubernetes.default.svc"],
  "exp": 1793503600,
  "iss": "https://kubernetes.default.svc.cluster.local",
  "kubernetes.io": {
    "namespace": "payments",
    "serviceaccount": { "name": "api" }
  },
  "sub": "system:serviceaccount:payments:api"
}
kubectl create token api --audience=vault --duration=10m

Since the cluster publishes an OIDC discovery document, external systems can verify those tokens.

Federation with no stored secrets

Cloud workload identity federation builds on that. A CI job gets an OIDC token from its platform, and exchanges it for short-lived cloud credentials. No cloud key is ever stored.

GitHub Actions job -> OIDC token (sub: repo:acme/app:ref:refs/heads/main)
                   -> AWS STS AssumeRoleWithWebIdentity
                   -> temporary credentials, valid for that job

The security rests on the trust policy. A condition that checks only the issuer, not the repository and branch, would let any repository on that platform assume the role. That is exactly the iss and aud validation discussed above, in another form.

Zero Trust

Traditional networks are a castle: a hard perimeter, and trust inside it. Once an attacker gets in through a phished VPN login, they can move laterally. With cloud services, remote work, and third parties, the perimeter no longer describes anything real.

The term “zero trust” comes from Forrester’s John Kindervag around 2010. Google’s BeyondCorp, published from 2014, showed it working at scale: employees reached internal apps from untrusted networks, with no VPN, through an access proxy that checked user and device.

NIST SP 800-207

NIST’s 2020 publication is the reference definition. It has seven tenets:

  1. All data sources and computing services are resources.
  2. All communication is secured, regardless of network location.
  3. Access to individual resources is granted per session.
  4. Access is determined by dynamic policy, including client identity, application, and observable state.
  5. The enterprise monitors the integrity and security posture of all owned and associated assets.
  6. Authentication and authorisation are dynamic, and strictly enforced before access is allowed.
  7. The enterprise collects as much information as possible about the state of assets, network, and communications, and uses it to improve its security posture.

The core sentence is simple: being on the network earns no trust.

The logical architecture

NIST separates decision from enforcement.

                    Control plane
          +----------------------------------+
          |  Policy Engine  (decides)        |
          |  Policy Administrator (executes) |
          +-----^--------------------|-------+
                |                    |
        inputs: identity, device,    | allow, deny, revoke
        threat intel, logs, policy   |
                |                    v
Subject ---> Policy Enforcement Point ---> Resource
                    Data plane
  • The Policy Engine makes the decision.
  • The Policy Administrator carries it out, and can tear down a session.
  • Together, they form the Policy Decision Point.
  • The Policy Enforcement Point sits in the path, and enables, monitors, and ends connections.

Mapping tokens to Zero Trust

OAuth was not designed as Zero Trust, but it fits this structure closely.

Zero Trust conceptOAuth or token equivalent
Policy Decision PointAuthorisation server, policy engine
Policy Enforcement PointAPI gateway, service mesh proxy, resource server
Per-session accessShort-lived, audience-restricted tokens
Least privilegeScopes, RAR, token exchange
Strong identityOIDC with phishing-resistant MFA
Device postureConditional access claims, mTLS device certs
Dynamic policyEvaluation at token issue, and at the PEP
Continuous evaluationShort expiry, Shared Signals, CAE
Encrypted communicationTLS, mTLS everywhere
Workload identitySPIFFE, federation
MonitoringToken and access logs

A key point: a JWT is a cached policy decision. The authorisation server evaluated policy at time T, and the token carries that verdict until exp. This is the deep conflict between tokens and Zero Trust. Tenet 4 wants decisions based on current state, and a token says what was true when it was issued.

Reducing that gap gives the range of designs:

DesignDecision ageDependency on the PDP
24-hour bearer tokenUp to a dayNone
5-minute tokenUp to 5 minutesLow
Token plus Shared SignalsSecondsEvent stream
IntrospectionZeroEvery request
PEP asks PDP each timeZeroEvery request

There is no universally right row. The higher the risk of the action, the fresher the decision should be. A read of a public catalogue tolerates a five-minute-old decision. A wire transfer does not.

Step-up authentication

RFC 9470 standardises the pattern of asking for more on demand. An API rejects a token that is not strong enough:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication",
  error_description="A more recent login is required",
  acr_values="urn:example:mfa", max_age=300

The client sends the user back through the flow with those requirements. The API checks acr and auth_time.

Continuous access evaluation

If tokens are cached decisions, the issuer needs a way to say they have gone stale. The OpenID Shared Signals Framework defines how systems send each other security events, as signed JWTs. CAEP defines session-related events, and RISC defines account risk events. Those three became Final Specifications in 2025.

{
  "iss": "https://idp.example",
  "aud": "https://api.example",
  "events": {
    "https://schemas.openid.net/secevent/caep/event-type/session-revoked": {
      "subject": { "format": "iss_sub", "iss": "https://idp.example", "sub": "user-42" },
      "event_timestamp": 1793500100
    }
  }
}

Microsoft’s Continuous Access Evaluation is a large deployed example. Resource services subscribe to critical events, such as an account being disabled, a password change, or a network location change. That lets tokens live longer, with an event able to end them in near real time. It solves the staleness and the outage dependency together, but the resource must understand the signals.

Service mesh and east-west traffic

Inside a cluster, Zero Trust means services authenticate each other, regardless of network position.

A mesh such as Istio or Linkerd, or Cilium’s mutual authentication, provides workload identity and mTLS with no application changes. The proxy is the PEP. Layered on this, the application still verifies the user’s token, since mTLS proves which service called, not on whose behalf.

Service A -> [proxy] == mTLS, SPIFFE ID ==> [proxy] -> Service B
                   Authorization: Bearer <user token, audience B>

Both identities matter. One says “this is the orders service”. The other says “acting for user 42”.

A policy engine ties these together, for example with OPA:

package httpapi.authz

default allow := false

allow if {
    input.source_spiffe_id == "spiffe://example.org/ns/shop/sa/orders"
    input.method == "GET"
    input.path[0] == "stock"
    input.token.aud == "api://inventory"
    "stock.read" in split(input.token.scope, " ")
}

The admission policy from the Kubernetes article is the same idea, at a different point in the system.

Maturity

CISA’s Zero Trust Maturity Model 2.0, from April 2023, has five pillars: identity, devices, networks, applications and workloads, and data. Three capabilities cut across them: visibility and analytics, automation and orchestration, and governance. Each moves through four stages: traditional, initial, advanced, and optimal.

What Zero Trust does not do

  • It is not a product, whatever the marketing says.
  • It does not remove the need for network segmentation. It adds to it.
  • It makes the identity provider the centre of everything, so that provider becomes a critical dependency and target.
  • It does not stop a legitimate user from abusing legitimate access. Monitoring has to address that.
  • It does not fix application bugs. A correct token still reaches vulnerable code.

Choosing a design

SituationReasonable design
Web app with one backendServer-side session, in a secure cookie
SPA with your own APIBFF with a session cookie
Mobile appCode flow with PKCE, system browser, rotated refresh token in secure storage
Third-party API accessOAuth code flow, narrow scopes, consent
Service to service, same organisationmTLS with SPIFFE, or client credentials with private_key_jwt
Microservices with user contextGateway validates, token exchange per audience
CLI toolDevice flow or loopback redirect
CI to cloudOIDC federation, with no stored keys
Financial or high-risk APIFAPI 2.0, DPoP or mTLS
Internal apps, no VPNIdentity-aware proxy, with device posture

Suggested lifetimes

These are common starting points, not standards. Adjust them to the risk.

TokenLifetime
Authorisation codeUnder 1 minute, one use
Access token5 to 15 minutes
Sensitive access tokenUnder 5 minutes, or sender-constrained
Refresh tokenDays to weeks, rotated
ID tokenMinutes, used once
DPoP proofSeconds
Web sessionIdle 30 to 60 minutes, absolute 12 to 24 hours
Workload JWTMinutes
Signing keyRotated every few months

The checklist

Issuing

  • Use an established authorisation server, rather than writing your own.
  • Sign with an asymmetric key, held in an HSM or KMS.
  • Set iss, aud, exp, iat, jti, and typ.
  • Use short lifetimes, and put no secrets in claims.
  • Use one audience per token, and narrow scopes.
  • Separate keys by purpose and environment.

Verifying

  • Pin the algorithm, and bind each key to one.
  • Validate iss, aud, exp, and typ.
  • Ignore jku, x5u, and embedded jwk.
  • Use a maintained library, and keep it patched.
  • Authorise at the object level in application code.

OAuth

  • Use code flow with PKCE.
  • Do not use implicit or password grants.
  • Match redirect URIs exactly.
  • Rotate or sender-constrain refresh tokens.
  • Do not send tokens in URLs.
  • Monitor and review third-party grants.

Sessions

  • Use 256 bits of CSPRNG output, and store only a hash.
  • Use __Host-, Secure, HttpOnly, and SameSite.
  • Issue a new ID at login and at privilege changes.
  • Enforce both idle and absolute timeouts.
  • Make logout end the server-side session.

Operations

  • Never log tokens, cookies, or Authorization headers.
  • Scrub HAR files before sharing them.
  • Rehearse signing key rotation before you need it.
  • Alert on refresh token reuse.
  • Log jti, sub, and client_id, so an incident can be traced.

What to take away

  • A JWT is a format, an access token is a role, and OAuth is delegation.
  • A stored lookup gives control, and a signed token gives independence.
  • The verifier chooses the algorithm, and checks the audience. Most token bugs are one of those two.
  • Sessions in secure cookies remain the best answer for many web applications.
  • Use the code flow with PKCE, and exchange tokens rather than forwarding them.
  • Bearer tokens are cash, and DPoP and mTLS add proof of possession.
  • A token is a cached policy decision, and its lifetime is how stale you allow that decision to be.
  • Zero Trust moves trust from the network to identity, and identity needs the same care as any critical system.