How OAuth 2.0 token flow works
How OAuth 2.0 authorization code flow, PKCE, refresh tokens, and token introspection work together to secure API access without sharing passwords.
The Interview Question
Interviewer: "Your web application uses 'Sign in with Google' and also calls the Google Calendar API on behalf of users. Walk me through exactly what happens when a user clicks that sign-in button, how your app gets an access token, and how you keep the user logged in without asking them to re-authenticate every hour."
This question tests whether you understand delegated authorization (not just authentication), the difference between access tokens and refresh tokens, why the authorization code flow exists instead of just passing tokens directly, and the security considerations that shaped the protocol. The interviewer is looking for depth beyond "we use OAuth."
What to Clarify Before Answering
You: "Let me clarify a few things before I walk through the flow..."
- "Are we talking about a server-rendered web app, a single-page app (SPA), or a mobile app? The flow differs for each."
- "Should I cover just the authorization code flow, or also client credentials (machine-to-machine) and device code (TV/CLI)?"
- "Is the concern about the token flow mechanics, or also about token storage and security best practices?"
- "Are we using OpenID Connect on top of OAuth, or pure OAuth 2.0 for authorization only?"
Why this matters: OAuth 2.0 is a framework with multiple grant types, each designed for a different client type. A server-side web app uses the authorization code flow. A mobile app uses authorization code with PKCE. A backend service calling another backend uses client credentials. Scoping the answer shows the interviewer you understand the design space.
The 30-Second Answer
OAuth 2.0 solves delegated authorization: letting a third-party application access a user's resources without the user sharing their password. The core mechanism is the authorization code flow: the user is redirected to the authorization server (Google, GitHub, etc.), authenticates there, grants consent, and the authorization server sends a short-lived authorization code back to the application. The application exchanges this code (plus its own client secret) for an access token and a refresh token. The access token is a short-lived credential (typically 5-60 minutes) used to call APIs. The refresh token is a long-lived credential used to obtain new access tokens without re-prompting the user. For public clients (SPAs, mobile apps) that cannot securely store a client secret, PKCE (Proof Key for Code Exchange) adds a cryptographic challenge to prevent authorization code interception.
The critical security insight is that the access token never passes through the browser's URL bar. The authorization code does, but it is useless without the client secret (or PKCE verifier). This two-step exchange is what makes OAuth 2.0 secure against token theft via browser history, referrer headers, and server logs.
The Architecture Overview
The four roles in OAuth 2.0 are the resource owner (user), the client (your app), the authorization server (Google, Okta, Auth0), and the resource server (the API you want to call).
I find the most common confusion is between the authorization server and the resource server. Google's login page is the authorization server. Google Calendar API is the resource server. They can be the same or different systems.
The authorization code is the key security mechanism. It is a one-time-use, short-lived (typically 30-60 seconds) code that the client exchanges for tokens. This exchange happens server-to-server, not through the browser, which is why the tokens are never exposed to the user's browser history or network logs.
The Authorization Code Flow: Step by Step
If you can explain this flow clearly in an interview, you demonstrate a real understanding of OAuth 2.0 security. Most candidates can only describe it at a high level; showing the actual HTTP requests and explaining why each parameter exists sets you apart.
This is the most important flow to understand. I will walk through every HTTP request and response.
Step 1: Redirect to Authorization Server
When the user clicks "Sign in with Google," the client constructs a URL and redirects the browser:
GET https://accounts.google.com/o/oauth2/v2/auth?
response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=openid%20email%20calendar.readonly
&state=random_csrf_token_abc123
&nonce=random_nonce_xyz789
| Parameter | Purpose |
|---|---|
response_type=code | Request an authorization code (not a token directly) |
client_id | Identifies your application (public, not secret) |
redirect_uri | Where to send the user after consent (must match registered URI) |
scope | What permissions you are requesting |
state | CSRF protection (random value, verified on callback) |
nonce | Replay protection for OpenID Connect ID tokens |
The state parameter prevents CSRF attacks
Without state, an attacker can initiate an OAuth flow with their own account and trick a victim into completing it, linking the attacker's account to the victim's session. Always generate a cryptographically random state value, store it in the session, and verify it matches on the callback. This is not optional.
Step 2: User Authenticates and Grants Consent
The authorization server shows its own login page (Google's login form, not yours). The user enters their credentials directly on Google's page. Your application never sees the password. After authentication, the authorization server shows a consent screen listing the requested scopes.
This is the core security property of OAuth: the resource owner's credentials are only ever entered on the authorization server's domain. The client application never touches them. Compare this to the pre-OAuth world where apps asked users for their Gmail password to read contacts. If the app was compromised, every user's password was exposed.
Step 3: Authorization Server Redirects Back with Code
After consent, the authorization server redirects the browser back to your redirect_uri:
HTTP/1.1 302 Found
Location: https://yourapp.com/callback?
code=4/0AX4XfWh_abc123_authorization_code
&state=random_csrf_token_abc123
Your server verifies state matches the value it stored in the session. The code is a one-time-use authorization code, valid for about 30-60 seconds.
Why not just return the token directly in the redirect?
The implicit flow (now deprecated in OAuth 2.1) did exactly that, returning the access token in the URL fragment. The problem: the token appears in browser history, can leak via the Referer header, and is visible to any JavaScript running on the page. The authorization code flow adds one extra step (code exchange) to keep tokens off the browser entirely. This tradeoff is always worth the added complexity.
Step 4: Exchange Code for Tokens (Server-to-Server)
Your backend makes a POST request directly to the token endpoint. This never goes through the browser:
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=4/0AX4XfWh_abc123_authorization_code
&redirect_uri=https://yourapp.com/callback
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
Step 5: Receive Tokens
The authorization server responds with tokens:
{
"access_token": "ya29.a0AfH6SMD_eyJhbGciOiJSUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "1//0dx2JHq_LONG_LIVED_REFRESH_TOKEN",
"scope": "openid email calendar.readonly",
"id_token": "eyJhbGciOiJSUzI1NiJ9.eyJpc3Mi..."
}
| Token | Lifetime | Purpose | Storage |
|---|---|---|---|
access_token | 5-60 minutes | Call APIs on behalf of user | Server-side session or httpOnly cookie |
refresh_token | Days to months | Get new access tokens silently | Server-side encrypted storage only |
id_token | Matches access token | User identity (OpenID Connect) | Verify once, then discard |
The key security insight
The access token never appears in the browser's address bar, history, or referrer headers. Only the authorization code does, and it is useless without the client_secret. This two-step exchange is the core security property of the authorization code flow.
PKCE: Securing Public Clients
PKCE was defined in RFC 7636 and has become the default recommendation for all OAuth 2.0 flows.
PKCE (Proof Key for Code Exchange, pronounced "pixy") solves a critical problem: mobile apps and SPAs cannot securely store a client_secret. Without a secret, anyone who intercepts the authorization code can exchange it for tokens.
The Problem
On mobile, a malicious app can register a custom URL scheme that matches your redirect URI. When the authorization server redirects with the authorization code, the malicious app intercepts it. Without PKCE, the attacker can exchange the stolen code for tokens because there is no client secret to verify the exchange.
How PKCE Works
- Before the auth request: The client generates a random
code_verifier(43-128 characters) and computescode_challenge = BASE64URL(SHA256(code_verifier)) - In the auth request: Send the
code_challenge(hashed value) to the authorization server - In the token exchange: Send the original
code_verifier(unhashed) to the authorization server - The auth server verifies:
SHA256(code_verifier) == code_challenge
// Step 1: Generate PKCE values
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" // Random
code_challenge = BASE64URL(SHA256(code_verifier))
// = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
// Step 2: Auth request includes challenge
GET /authorize?...&code_challenge=E9Melhoa2...&code_challenge_method=S256
// Step 3: Token exchange includes verifier
POST /token
grant_type=authorization_code
&code=AUTH_CODE
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
The attacker intercepts the auth code but does not have the code_verifier (it was stored in memory on the legitimate client). The code_challenge was sent over HTTPS to the auth server, so the attacker cannot reverse-engineer the verifier from the challenge (SHA256 is one-way).
PKCE is now recommended for ALL clients
OAuth 2.1 (the upcoming standard) makes PKCE mandatory for all authorization code flows, even confidential clients with a client_secret. It adds defense-in-depth against code injection attacks. If you are building a new OAuth integration, use PKCE regardless of client type.
Understanding the three token types and their different lifetimes, storage requirements, and validation methods is critical. I find that most OAuth bugs come from treating all tokens the same way.
Access Tokens
Access tokens are the credentials used to call protected APIs. They come in two forms, and choosing the right one affects your architecture significantly
Access tokens are the credentials used to call protected APIs. They come in two forms:
Self-contained (JWT): The token itself contains the user identity, scopes, and expiration. The resource server validates it locally by checking the signature against the authorization server's public key (from the JWKS endpoint). No network call needed.
// Decoded JWT access token
{
"header": { "alg": "RS256", "kid": "key-id-123" },
"payload": {
"iss": "https://accounts.google.com", // Who issued it
"sub": "user-id-456", // Who it represents
"aud": "your-client-id", // Who it is for
"exp": 1712345678, // Expiration (Unix timestamp)
"iat": 1712342078, // Issued at
"scope": "openid email calendar.readonly" // What it allows
},
"signature": "RSA signature of header.payload"
}
Opaque tokens: A random string that means nothing by itself. The resource server must call the authorization server's introspection endpoint to validate it. More secure (tokens can be revoked instantly) but adds latency.
| Token Type | Validation | Revocation | Latency | Use Case |
|---|---|---|---|---|
| JWT (self-contained) | Local signature check | Cannot revoke before expiry | ~0ms | High-throughput APIs |
| Opaque | Introspection call to auth server | Instant revocation | ~5-50ms | Security-sensitive APIs |
Refresh Tokens
Refresh tokens solve the UX problem of short-lived access tokens. Without them, users would have to re-authenticate every 5-60 minutes. The refresh token lets the client silently obtain new access tokens.
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=1//0dx2JHq_LONG_LIVED_REFRESH_TOKEN
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
The authorization server validates the refresh token, checks it has not been revoked, and returns a new access token (and optionally a new refresh token).
Refresh Token Rotation: Detecting Theft
Refresh token rotation is the most important security mechanism for long-lived sessions. Here is how it detects token theft:
This mechanism is not perfect (the legitimate user gets logged out), but it limits the damage window. Without rotation, a stolen refresh token grants indefinite access.
- Client receives refresh token RT1
- Client uses RT1 to get a new access token. Server returns new RT2, invalidates RT1
- Client uses RT2 next time. Server returns RT3, invalidates RT2
- Attacker stole RT1 earlier. Attacker tries to use RT1. Server sees RT1 was already used (invalidated). This means either the client or the attacker is replaying. Server revokes the entire token family (RT1, RT2, RT3, all descendants)
The legitimate user must re-authenticate, but the attacker is locked out.
Store refresh tokens server-side only
Refresh tokens must never be stored in localStorage, sessionStorage, or any client-side JavaScript-accessible storage. For web apps, store them in an encrypted, server-side session. For mobile apps, use the platform's secure storage (iOS Keychain, Android Keystore). A stolen refresh token is equivalent to a stolen session.
Token Storage: Where to Keep Tokens
Token storage is where most OAuth implementations get security wrong. The correct approach depends on the client type.
| Client Type | Access Token Storage | Refresh Token Storage | Why |
|---|---|---|---|
| Server-side web app | Server-side session | Server-side encrypted DB | Tokens never leave the server |
| SPA (Single Page App) | In-memory variable | httpOnly, Secure, SameSite cookie (via BFF) | localStorage is vulnerable to XSS |
| Mobile app | Secure enclave (Keychain/Keystore) | Secure enclave | Platform-level protection |
| CLI tool | OS credential store | OS credential store | Keyring on Linux, Credential Manager on Windows |
Other Grant Types
Understanding the authorization code flow deeply is the priority, but interviewers sometimes ask about other flows to test breadth.
Client Credentials (Machine-to-Machine)
When a backend service calls another backend service with no user involved, there is no browser redirect. The client authenticates directly with its credentials:
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=SERVICE_A_ID
&client_secret=SERVICE_A_SECRET
&scope=api.read
No authorization code, no user consent, no refresh token. The access token represents the application itself, not a user. This is the simplest flow.
I use client credentials for internal microservice communication where both services are trusted. The scopes are pre-configured per client, not user-granted. The main risk is credential leakage: treat the client_secret like a database password (use secrets management, never commit to source control).
Device Code (TV, CLI, IoT)
For devices without a browser or keyboard (smart TVs, CLI tools, IoT devices):
Grant Type Decision Matrix
| Scenario | Grant Type | Why |
|---|---|---|
| Server-side web app | Authorization Code | Has a backend to store client_secret |
| Single Page App (SPA) | Authorization Code + PKCE | No backend secret, PKCE protects code |
| Mobile/Desktop app | Authorization Code + PKCE | Cannot securely embed client_secret |
| Backend service to service | Client Credentials | No user context needed |
| Smart TV / CLI / IoT | Device Code | No browser or keyboard on device |
| Legacy migration (avoid) | Resource Owner Password | User gives password to client (deprecated) |
The implicit flow is deprecated
OAuth 2.1 removes the implicit flow entirely. It returned tokens directly in the URL fragment, exposing them to browser history and referrer leaks. If you see response_type=token in any codebase, migrate to authorization code with PKCE.
Scope and Consent: Granular Permissions
Scopes define what the client is allowed to do with the access token. They follow the principle of least privilege.
// Request only what you need
scope=openid email // Just identity
scope=openid email calendar.readonly // Read calendar events
scope=openid email calendar.events.write // Create/modify events
The most dangerous failures in OAuth are silent. A CSRF attack with a missing state parameter does not throw an error. The victim gets logged into the attacker's account and may not notice. This is why security testing must specifically exercise these attack vectors.
Always validate the redirect URI on the server side
The authorization server must exact-match the redirect URI against a pre-registered list. Allowing wildcards or partial matches (e.g., https://yourapp.com/*) enables open redirect attacks: an attacker constructs a redirect URI to their own server, intercepting the authorization code. This is one of the most exploited OAuth vulnerabilities.
The authorization server enforces scopes at the token level. Even if a client requests calendar.events.write, the access token returned will only contain scopes the user actually consented to.
At the resource server, scope enforcement is a simple check:
// Resource server middleware
function requireScope(requiredScope) {
return (req, res, next) => {
const tokenScopes = req.token.scope.split(' ');
if (!tokenScopes.includes(requiredScope)) {
return res.status(403).json({ error: 'insufficient_scope' });
}
next();
};
}
// Usage
app.get('/api/calendar', requireScope('calendar.readonly'), handler);
app.post('/api/calendar', requireScope('calendar.events.write'), handler);
Device Code Flow
For devices without a browser or keyboard (smart TVs, CLI tools, IoT devices):
- Device requests a device code and a user code from the auth server
- Device displays: "Go to https://example.com/activate and enter code: ABCD-1234"
- User opens URL on their phone/laptop, enters the code, authenticates
- Device polls the token endpoint until the user completes authentication
- Auth server returns tokens
This flow is elegant because the device never needs to handle user credentials directly.
Token Introspection and Revocation
Introspection and revocation are the server-side mechanisms that complete the token lifecycle. While most developers focus on obtaining tokens, understanding how to validate and invalidate them is equally important for production systems.
Introspection
For opaque tokens (or when the resource server wants to verify revocation status of a JWT), the introspection endpoint provides token metadata:
POST /introspect
Content-Type: application/x-www-form-urlencoded
token=ACCESS_TOKEN_VALUE
&token_type_hint=access_token
Response:
{
"active": true,
"sub": "user-id-456",
"scope": "openid email calendar.readonly",
"exp": 1712345678,
"client_id": "your-client-id"
}
Revocation
When a user logs out or an admin disables an account, tokens should be revoked:
POST /revoke
Content-Type: application/x-www-form-urlencoded
token=REFRESH_TOKEN_VALUE
&token_type_hint=refresh_token
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
JWT revocation is an unsolved hard problem
Revoking a JWT before its expiration requires maintaining a blocklist of revoked token IDs (jti claim). Every resource server must check this blocklist on every request, which defeats the main advantage of JWTs (stateless validation). The pragmatic solution is to keep access token lifetimes short (5-15 minutes) so revocation becomes "wait for expiry." Use opaque tokens with introspection if instant revocation is critical.
What Happens When Things Break
| Failure | What Happens | How to Detect | How to Fix |
|---|---|---|---|
| Access token expired | API returns 401 Unauthorized | Check exp claim before calling API | Automatically refresh using refresh token |
| Refresh token revoked | Token endpoint returns invalid_grant | 400 response on refresh attempt | Redirect user to re-authenticate |
| Authorization code replay | Token endpoint rejects second use | 400 invalid_grant on code exchange | Codes are one-time-use by design; retry the flow |
| Redirect URI mismatch | Auth server rejects the request | Error on /authorize, no callback | Verify registered redirect URIs in provider config |
| CSRF via missing state | Attacker links their account to victim's session | Difficult to detect after the fact | Always generate and verify the state parameter |
| Token theft via XSS | Attacker exfiltrates tokens from localStorage | Monitor for unusual API access patterns | Use httpOnly cookies, implement CSP, audit for XSS |
Performance Characteristics
| Operation | Latency | Notes |
|---|---|---|
| Authorization redirect | ~200-500ms | Browser redirect + page load on auth server |
| Code exchange (server-to-server) | ~50-200ms | HTTPS POST to token endpoint |
| JWT validation (local) | less than 1ms | RSA signature verification with cached public key |
| Token introspection (network) | ~5-50ms | HTTPS POST to introspection endpoint |
| Refresh token exchange | ~50-200ms | HTTPS POST to token endpoint |
| JWKS key fetch | ~50-200ms (cached) | Fetch once, cache for hours. Re-fetch on key rotation |
For high-throughput APIs, JWT validation is the clear winner at sub-millisecond latency. I cache the JWKS public keys for the max-age duration specified in the Cache-Control header (typically 1-24 hours). The only time I use introspection is when instant revocation is a hard requirement (financial APIs, healthcare).
In practice, I recommend a hybrid approach: use JWTs for read operations (where a few minutes of stale access is tolerable) and add introspection checks for write operations on sensitive resources. This gives you performance where it matters and security where it matters.
How This Compares to Alternatives
| Feature | OAuth 2.0 + OIDC | SAML 2.0 | API Keys | Session Cookies |
|---|---|---|---|---|
| Delegated access | Yes (core purpose) | Yes (enterprise SSO) | No | No |
| Token format | JWT or opaque | XML assertions | Opaque string | Opaque cookie |
| Mobile/SPA support | Excellent (PKCE) | Poor (XML, browser redirects) | Simple but insecure | Complex (CSRF) |
| Granular scopes | Yes | Coarse-grained | Usually all-or-nothing | Not applicable |
| Token refresh | Built-in | Session-based | Manual rotation | Session expiry |
| Complexity | Medium-high | High (XML, certificates) | Low | Low |
| Standard adoption | Universal (Google, GitHub, Azure) | Enterprise (Okta, ADFS) | Widespread | Universal |
I use OAuth 2.0 + OIDC for any application that needs user authentication or third-party API access. I use API keys only for server-to-server communication where simplicity matters more than granular authorization. I avoid SAML for new projects unless the enterprise customer requires it. Session cookies are fine for simple web apps that do not need delegated access.
Interview Cheat Sheet
- When asked what OAuth solves: "OAuth is a delegated authorization framework. It lets users grant third-party apps limited access to their resources without sharing passwords. The app gets a scoped, time-limited access token instead."
- When asked about the authorization code flow: "The user authenticates at the authorization server, which redirects back with a one-time authorization code. The client exchanges this code (plus client_secret) for access and refresh tokens. The exchange happens server-to-server, so tokens never appear in the browser."
- When asked about PKCE: "PKCE replaces the client_secret for public clients (SPAs, mobile). The client generates a random verifier, sends its hash in the auth request, and proves it knows the original verifier during code exchange. Prevents authorization code interception."
- When asked about token storage: "Server-side apps store tokens in encrypted sessions. SPAs should use the Backend-for-Frontend pattern where the SPA never touches tokens. Mobile apps use platform secure storage (Keychain/Keystore). Never use localStorage."
- When asked about refresh tokens: "Refresh tokens are long-lived credentials that obtain new access tokens without user interaction. With rotation, each refresh invalidates the previous token. If both the old and new token are used, the server detects theft and revokes the entire family."
- When asked about JWT vs opaque tokens: "JWTs are self-contained (validate locally, sub-millisecond) but cannot be revoked before expiry. Opaque tokens require introspection (network call) but support instant revocation. Use short-lived JWTs for performance, opaque tokens when revocation is critical."
- When asked about client credentials: "Machine-to-machine flow with no user involvement. The service authenticates with client_id and client_secret directly at the token endpoint. No redirect, no browser, no refresh token."
- When asked about security pitfalls: "Missing state parameter enables CSRF. Storing tokens in localStorage enables XSS theft. Long-lived access tokens expand the attack window. Missing PKCE on public clients enables code interception. These are the four most common OAuth security mistakes."
Test Your Understanding
These questions test whether you can apply OAuth 2.0 concepts to real security scenarios. Each one requires reasoning about the protocol mechanics, not just recalling the flow diagram.
Quick Recap
- OAuth 2.0 provides delegated authorization, letting apps access user resources without seeing user passwords.
- The authorization code flow uses a two-step exchange (code then token) so that access tokens never pass through the browser.
- PKCE adds a cryptographic proof-of-possession for public clients (SPAs, mobile) that cannot store a client_secret.
- Access tokens are short-lived (5-60 minutes), either self-contained JWTs (fast local validation) or opaque tokens (require introspection).
- Refresh tokens are long-lived credentials that obtain new access tokens silently. Refresh token rotation detects theft by invalidating the entire token family on reuse.
- Token storage must match the client type: server-side sessions for web apps, BFF pattern for SPAs, platform secure storage for mobile.
- Client credentials flow (no user, no browser) is for machine-to-machine communication. Device code flow is for devices without browsers.
- The
stateparameter prevents CSRF attacks, PKCE prevents code interception, and short token lifetimes limit the window of compromise. - OAuth 2.1 deprecates the implicit flow and makes PKCE mandatory, simplifying the security model.
- The Backend-for-Frontend (BFF) pattern is the most secure token storage approach for SPAs, keeping all tokens server-side.
Related Concepts
- JWT (JSON Web Tokens): The most common access token format. Understanding JWT structure, signing algorithms (RS256 vs HS256), and validation is essential for implementing OAuth resource servers.
- OpenID Connect (OIDC): A thin identity layer on top of OAuth 2.0. Adds the ID token, UserInfo endpoint, and standard claims for authentication (OAuth itself is only authorization).
- SSL/TLS Certificates: OAuth security depends on HTTPS. The entire protocol breaks if the connection is not encrypted, as authorization codes and tokens would be visible to network observers.
- Session Management: OAuth tokens eventually back a user session. How you manage that session (cookie settings, session fixation prevention, idle timeout) is equally important to the OAuth flow itself.