- OAuth 2.0 is delegated authorization (get an access token to call an API). OpenID Connect adds authentication (an ID token saying who logged in). JWT is just a token format that both often use.
- For almost every client today, use the authorization code flow with PKCE. Use client credentials for service-to-service. Implicit and password grants are deprecated.
- A JWT is
base64url(header).base64url(payload).signature. It is signed, not encrypted: anyone can read it. Verify signature,algallowlist,iss,aud,expon every request. - Short-lived access tokens (5 to 15 min) plus rotating refresh tokens with reuse detection is the standard way to get statelessness and revocability together.
- Most real breaches come from boring mistakes: accepting
alg: none, not checkingaud, open redirect URIs, tokens in localStorage, and long-lived tokens with no revocation path.
Most backend engineers use OAuth daily through a library and a "Sign in with Google" button, and then freeze in an interview when asked "why does the authorization code flow exist at all, if the server could just return a token?" This guide builds the model from the ground up: which spec solves which problem, what travels over the wire in each step, how a JWT is actually verified, and where implementations go wrong. Everything here maps to the current specs: RFC 6749 (OAuth 2.0), RFC 7636 (PKCE), RFC 7519 (JWT), RFC 9700 (OAuth Security Best Current Practice) and OpenID Connect Core 1.0.
OAuth 2.0 vs OpenID Connect vs JWT: Three Different Things
The single most common confusion is treating these as alternatives. They sit at different layers and usually appear together.
| What it is | Question it answers | Key artifact | |
|---|---|---|---|
| OAuth 2.0 | Authorization framework (RFC 6749) | "May this app call this API on the user's behalf, with which permissions?" | Access token, refresh token, scopes |
| OpenID Connect (OIDC) | Identity layer on top of OAuth 2.0 | "Who is the user who just logged in, and when/how did they authenticate?" | ID token (always a JWT), /userinfo, discovery document |
| JWT | Token format (RFC 7519) plus JWS/JWE for signing/encryption | "How do I pack claims into a compact, verifiable string?" | header.payload.signature |
An OAuth access token does not have to be a JWT. Many providers issue opaque tokens (random strings) that the API validates by calling an introspection endpoint (RFC 7662). Conversely, you can use JWTs with no OAuth at all, for example a monolith that issues its own session JWT after password login. When an interviewer says "we use JWT for auth", the right first question is: "issued by whom, verified by whom, and is there an OAuth/OIDC provider involved?"
The Four OAuth Roles (and the Problem OAuth Solves)
Before OAuth, if a photo-printing site wanted your Google Photos, it asked for your Google password. That gave it full, permanent, unrevocable access to everything. OAuth's job is to let the user grant limited, revocable, scoped access without sharing credentials. It defines four roles:
- Resource owner: the user who owns the data.
- Client: the application that wants access (the printing site, your mobile app, your SPA, a cron job).
- Authorization server (AS): authenticates the user, shows the consent screen and issues tokens. Examples: Google, Auth0, Okta, Keycloak, AWS Cognito, or your own.
- Resource server (RS): the API holding the data. It accepts access tokens and enforces scopes.
Clients come in two kinds. A confidential client runs on a server you control and can keep a client_secret. A public client (SPA, mobile app, desktop app, CLI) ships its code to users, so any embedded secret is extractable in minutes with a decompiler or browser devtools. This distinction drives which grant type you use and why PKCE exists.
The Authorization Code Flow with PKCE, Step by Step
This is the flow behind "Sign in with Google" and the one you should be able to draw on a whiteboard. Suppose a food-ordering app (client) wants to read the user's profile and calendar from an identity provider.
- Generate PKCE values. The client creates a high-entropy random
code_verifier(43 to 128 characters) and computescode_challenge = BASE64URL(SHA256(code_verifier)). It also creates a randomstate(CSRF protection) and, for OIDC, anonce. It stores these in the session. - Redirect to the authorize endpoint. The browser goes to the AS with
response_type=code,client_id,redirect_uri,scope,state,code_challengeandcode_challenge_method=S256. - User authenticates and consents at the AS. The client never sees the password.
- AS redirects back with a short-lived code. The code is single-use and typically valid for under a minute. It travels through the browser's URL, so it can leak via logs, history or a malicious app registered for the same custom URL scheme.
- Client validates
stateagainst what it stored. Mismatch means a forged callback; abort. - Back-channel token exchange. The client POSTs the code,
redirect_uriand the originalcode_verifier(plusclient_secretif confidential) directly to the AS over TLS. The AS hashes the verifier and checks it equals the stored challenge. - AS returns tokens:
access_token,expires_in, usuallyrefresh_token, andid_tokenif theopenidscope was requested.
Why the two-step dance? Answering the classic interview question
Why not just return the access token in step 5? Because step 5 travels through the front channel (browser redirects, URL bars, referrer headers, browser history, proxies). Anything there should be assumed observable. The code is useless on its own: redeeming it requires the back-channel call, which needs the client secret (confidential clients) or the PKCE verifier (everyone). The verifier never left the client, and an attacker who sniffed the code cannot reverse SHA-256 to get it. That is the whole point of PKCE: it binds the code to the instance of the client that started the flow.
import base64, hashlib, secrets
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
code_verifier = b64url(secrets.token_bytes(32)) # 43 chars
code_challenge = b64url(hashlib.sha256(code_verifier.encode("ascii")).digest())
state = secrets.token_urlsafe(16)
print(code_verifier, code_challenge, state)
Use S256, never plain. The plain method sends the verifier itself as the challenge, which defeats the purpose if the authorization request leaks.
OAuth 2.0 Grant Types Compared (and Which Are Deprecated)
A "grant" is a way of obtaining an access token. Interviewers like asking you to choose one for a given client, so learn the decision, not just the list.
| Grant | Use it for | User involved? | Status |
|---|---|---|---|
| Authorization code + PKCE | Web apps, SPAs, mobile, desktop | Yes | Recommended default |
| Client credentials | Service-to-service, cron jobs, backend workers | No (client acts as itself) | Recommended |
| Refresh token | Getting a new access token without re-login | No (already granted) | Recommended, with rotation for public clients |
| Device authorization (RFC 8628) | Smart TVs, CLIs, devices without a browser or keyboard | Yes, on a second device | Recommended for that niche |
| Implicit | Old SPAs: token returned directly in URL fragment | Yes | Deprecated (RFC 9700, OAuth 2.1) |
| Resource owner password credentials | App collects username/password and sends to AS | Yes | Deprecated; defeats the purpose of OAuth |
Client credentials in practice
When your order service calls your payment service, there is no user. The order service authenticates with its own client_id and client_secret (or better, a signed JWT assertion or mTLS) and gets a token with scopes like payments:create.
POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)
grant_type=client_credentials&scope=payments:create
Cache that token until shortly before expires_in rather than fetching one per request; token endpoints are commonly rate limited and become a hidden bottleneck.
Device flow, the "enter this code on your phone" pattern
The TV app calls /device_authorization, gets a user_code like WDJB-MJHT and a verification_uri, shows them on screen, and polls the token endpoint every few seconds. The user opens the URL on a phone, logs in and types the code. Streaming apps and the GitHub CLI use this pattern.
OpenID Connect: Adding Authentication on Top of OAuth
Plain OAuth gives the client an access token, but the client has no standard way to learn who the user is. Early "login with X" implementations called a profile API with the access token and trusted the result, which led to the confused deputy problem: a token issued to app A could be replayed to app B, which would log the attacker in as the victim. OIDC fixes this with the ID token, a JWT whose audience is explicitly the client.
To use OIDC, add openid to the scopes (plus profile, email as needed). You get:
- ID token with claims:
iss(issuer),sub(stable user ID at that issuer),aud(your client ID),exp,iat,nonce,auth_time, optionallyemail,name,amr(how they authenticated, e.g. password plus OTP). - /userinfo endpoint for additional profile claims, called with the access token.
- Discovery at
/.well-known/openid-configuration, a JSON document listing the authorize/token/userinfo endpoints and thejwks_uriwhere signing keys are published.
ID token vs access token: who is the audience?
The ID token is for the client: it reads it once at login to create a session. The access token is for the API. Two bugs follow from mixing them up: sending the ID token to your API as a bearer token (the API then accepts tokens issued for other clients if it does not check aud), and the frontend decoding the access token to make UI decisions (it may be opaque, and its format is a contract between AS and API, not the client).
JWT Structure: Header, Payload, Signature
A JWT in compact form is three base64url-encoded segments joined by dots. Here is a decoded example of an access token your API might receive:
{
"alg": "RS256",
"typ": "at+jwt",
"kid": "2026-09-key-1"
}
{
"iss": "https://auth.example.com",
"sub": "user_8f3a21",
"aud": "https://api.example.com",
"exp": 1791620400,
"iat": 1791619500,
"nbf": 1791619500,
"jti": "a7c1e0b2-55e4-4a1e-9d1b-3f0c7a9e2d11",
"scope": "orders:read orders:write",
"client_id": "food-app-android"
}
The signature is computed over the exact bytes base64url(header) + "." + base64url(payload) using the algorithm in the header. For RS256, the AS signs with its RSA private key; verifiers check with the public key, fetched from the JWKS URL and selected by kid.
| Claim | Meaning | What the verifier does |
|---|---|---|
iss | Issuer | Must exactly equal the expected issuer URL |
aud | Intended recipient | Must contain this API's identifier |
exp | Expiry (Unix seconds) | Reject if now > exp (allow 30 to 60 s clock skew) |
nbf / iat | Not before / issued at | Reject tokens from the future |
sub | Subject (user or client) | Used as the principal ID |
jti | Unique token ID | Used for denylists and replay detection |
scope | Granted permissions | Checked per endpoint |
Signed is not encrypted
Paste any JWT into a base64 decoder and the payload is readable. A signed JWT (JWS) guarantees integrity and authenticity, not confidentiality. Never put PAN numbers, Aadhaar numbers, phone numbers or internal secrets in a JWT payload unless you use JWE (encrypted JWT), and even then prefer a reference ID. Also remember size: every claim is sent on every request, and many proxies cap headers at around 8 KB.
HS256 vs RS256 vs ES256
| Algorithm | Key type | Who can verify | Who can mint | Use when |
|---|---|---|---|---|
| HS256 | Shared HMAC secret | Anyone with the secret | Anyone with the secret | Issuer and verifier are the same service |
| RS256 | RSA key pair | Anyone with the public key | Only the private key holder | Many microservices verify; widest library support |
| ES256 | ECDSA P-256 key pair | Public key holders | Private key holder | Same as RS256 with much smaller signatures |
| EdDSA | Ed25519 | Public key holders | Private key holder | Modern, fast; check library support first |
With HS256 across 20 microservices, all 20 hold a secret that can forge an admin token. A compromise of the least-protected service compromises everyone. Asymmetric signing limits minting power to the AS.
How to Verify a JWT Correctly (Code in Python and Java)
The verification checklist, in order:
- Parse the header without trusting it. Read
kidto select the key. - Enforce an algorithm allowlist configured on the server, e.g. only
RS256. Never let the token'salgchoose the algorithm. - Fetch the key from JWKS (cached, with refresh on unknown
kidto support key rotation, and rate-limited so attackers cannot force refetch storms). - Verify the signature.
- Validate claims:
iss,aud,exp,nbf, andtypif your AS sets it. - Authorize: check scopes/roles for this endpoint and, for user data, that
subowns the resource.
import jwt
from jwt import PyJWKClient
ISSUER = "https://auth.example.com"
AUDIENCE = "https://api.example.com"
jwks_client = PyJWKClient(f"{ISSUER}/.well-known/jwks.json", cache_keys=True)
def verify_access_token(token: str) -> dict:
signing_key = jwks_client.get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"], # allowlist, never taken from the token
audience=AUDIENCE,
issuer=ISSUER,
leeway=30, # seconds of clock skew
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)
return claims
def require_scope(claims: dict, needed: str) -> None:
if needed not in claims.get("scope", "").split():
raise PermissionError(f"missing scope {needed}")
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import java.net.URL;
import java.util.Set;
public final class AccessTokenVerifier {
private final DefaultJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
public AccessTokenVerifier(String issuer, String audience) throws Exception {
JWKSource<SecurityContext> keys = JWKSourceBuilder
.create(new URL(issuer + "/.well-known/jwks.json"))
.build(); // cached, refreshes on unknown kid
processor.setJWSKeySelector(
new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, keys)); // alg allowlist
processor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
audience,
new JWTClaimsSet.Builder().issuer(issuer).build(), // exact-match claims
Set.of("sub", "exp", "iat"))); // required claims
}
public JWTClaimsSet verify(String token) throws Exception {
return processor.process(token, null); // throws on bad signature, exp, iss, aud
}
}
In Spring Boot you would normally not write this yourself: spring-boot-starter-oauth2-resource-server with spring.security.oauth2.resourceserver.jwt.issuer-uri configures discovery, JWKS caching and iss/exp checks. Audience validation, however, is something you often have to add explicitly with a custom validator. Know that detail; it is a frequent follow-up.
Access Tokens vs Refresh Tokens: Lifetimes, Rotation and Revocation
The fundamental tension: self-contained JWT access tokens are fast (no database lookup per request) but cannot be revoked; server-side sessions are revocable but need a lookup. The access plus refresh token pair resolves it.
| Access token | Refresh token | |
|---|---|---|
| Sent to | Resource server, on every request | Authorization server only |
| Typical lifetime | 5 to 60 minutes | Days to months, sliding or absolute |
| Format | Often JWT (or opaque) | Usually opaque, stored server-side |
| Revocation | Hard; rely on short expiry | Easy; delete the row |
| If stolen | Attacker has access until expiry | Attacker can mint access tokens until revoked |
Refresh token rotation with reuse detection
For public clients, best practice is to issue a new refresh token on every refresh and invalidate the old one. If an old refresh token is ever presented again, either the legitimate client or an attacker is replaying it, and the AS cannot tell which, so it revokes the entire token family and forces re-login.
def refresh(presented_token):
row = db.refresh_tokens.find(hash(presented_token))
if row is None:
raise Unauthorized()
if row.used:
# Reuse detected: someone holds a copy. Kill the whole family.
db.refresh_tokens.revoke_family(row.family_id)
audit_log("refresh_token_reuse", row.user_id, row.family_id)
raise Unauthorized()
if row.expires_at < now() or row.family_revoked:
raise Unauthorized()
db.refresh_tokens.mark_used(row.id)
new_rt = random_token()
db.refresh_tokens.insert(hash(new_rt), family_id=row.family_id,
user_id=row.user_id, expires_at=row.absolute_expiry)
access = sign_jwt(sub=row.user_id, scope=row.scope, ttl_minutes=10)
return {"access_token": access, "refresh_token": new_rt, "expires_in": 600}
Note two details: store a hash of the refresh token, not the token, so a database leak does not leak live credentials; and keep an absolute expiry on the family so rotation does not create infinite sessions. In practice you also need a small grace window for concurrent refreshes (two browser tabs refreshing at once) or you will log out legitimate users.
Revoking access tokens before expiry
- Keep them short (10 minutes is common). Revoking the refresh token then cuts access within one TTL.
- Denylist by
jtiin Redis with TTL equal to the token's remaining life, checked at the gateway. This reintroduces a lookup, but only a cheap key-exists check. - Per-user "tokens valid after" timestamp: on password change or "log out of all devices", store
min_iatfor the user and reject tokens withiatbefore it. - Opaque tokens plus introspection for high-risk operations such as payouts, where per-request validation cost is acceptable.
Where to Store Tokens: Browser, Mobile and Services
| Client | Recommended storage | Avoid |
|---|---|---|
| Server-rendered web app | Tokens in server session; browser gets HttpOnly, Secure, SameSite=Lax cookie | Sending tokens to the browser at all |
| SPA (React/Angular) | Backend-for-frontend (BFF) holding tokens; or access token in memory with refresh via HttpOnly cookie | localStorage / sessionStorage for refresh tokens |
| Android | EncryptedSharedPreferences / Android Keystore | Plain SharedPreferences, logs |
| iOS | Keychain | UserDefaults |
| Backend service | In memory, fetched with client credentials; secrets in a vault | Hardcoded secrets in repo or Docker image |
Cookies bring CSRF back into the picture; mitigate with SameSite attributes plus a CSRF token or checking the Origin header on state-changing requests. Tokens in JavaScript-accessible storage bring XSS risk. There is no free option, which is why the BFF pattern has become the commonly recommended default for browser apps handling sensitive data.
The Security Mistakes That Actually Get APIs Breached
Trusting the alg header
Accepting "alg": "none", or the RS256-to-HS256 confusion attack where the attacker signs with HMAC using your public key as the secret. Fix: server-side algorithm allowlist, and keep keys typed.
Not checking aud
Any token from your IdP, issued to any app (including an attacker's free-tier app on the same IdP), is accepted. Fix: always validate audience.
Loose redirect URI matching
Prefix or wildcard matching lets attackers redirect codes to https://app.com.evil.io or an open redirect on your domain. Fix: exact string match of registered URIs.
Missing state / nonce
Without state, an attacker can log a victim into the attacker's account (login CSRF). Without nonce, ID tokens can be replayed. Fix: generate, store and compare both.
Decode instead of verify
Calling jwt.decode(token, options={"verify_signature": False}) "temporarily" and shipping it. Fix: code review rule and tests with tampered tokens.
Long-lived tokens, no revocation
30-day JWT access tokens mean a leaked token from a log file works for a month. Fix: short access TTL, revocable refresh tokens.
A few more that show up in security reviews: logging full Authorization headers (scrub them in your logging middleware); putting tokens in query strings (they end up in access logs and referrers); using the same signing key forever (rotate keys and publish both old and new in JWKS during overlap); and authorizing only by scope while forgetting object-level checks. A token with orders:read must still only read that user's orders. Broken object-level authorization is the top item on the OWASP API Security Top 10 for a reason.
How OAuth and JWT Come Up in Interviews
In backend and system design rounds at product companies, auth rarely appears as "explain OAuth" in isolation. It appears inside a design: "your API gateway authenticates requests, how?" or "how does the mobile app stay logged in for 30 days?" For a full design of a login system with OTP, sessions and SSO, see Design an Authentication & SSO System; for where token checks sit in the request path, see Design an API Gateway.
Questions to expect and strong answer outlines
- "JWT or sessions?" Strong answer: sessions for a single web app (simple, revocable); JWT access tokens when many stateless services verify identity and you want to avoid a central lookup on every hop; and almost always a revocable refresh token behind them. Mention the revocation trade-off unprompted.
- "How does service A call service B securely?" Client credentials grant with scoped tokens, or mTLS in a service mesh, plus propagating the end-user identity separately (token exchange, RFC 8693) if B needs to know the user.
- "A user changed their password. How do you log out their other devices?" Revoke all refresh token families for the user, set a
min_iat, and accept up to one access-token TTL of lag, or denylist activejtis if that lag is unacceptable. - "How do you rotate signing keys without downtime?" Publish the new public key in JWKS first, wait for verifier caches to refresh, start signing with the new
kid, keep the old key published until all tokens signed with it have expired, then remove it. - "Where does verification happen?" At the gateway for coarse checks (signature, expiry, audience), and again in services for fine-grained authorization. Do not let internal services blindly trust an
X-User-Idheader unless the network path guarantees it came from the gateway.
Red flags interviewers notice
- Saying "JWT is encrypted" or "OAuth is for login".
- Proposing the implicit flow for a new SPA.
- Storing refresh tokens in localStorage without acknowledging XSS.
- No answer for revocation, or "we'll just make tokens expire in 24 hours".
- Treating the token's scopes as complete authorization and skipping ownership checks.
If you are preparing for broader API rounds, pair this with REST API interview questions and the auth-related items in microservices interview questions. Protecting the token endpoint itself from brute force and credential stuffing is a rate limiting problem; Design a Rate Limiter covers that.
Frequently Asked Questions
Master System Design Interviews
Prepflix's system design track covers auth, API gateways and security trade-offs with mock interviews that probe exactly these follow-ups.
Explore the Prepflix Course →