OAuth 2.0 Explained: How Authorization Works Step by Step
A 3-Million Account Breach That Started With One OAuth Misconfiguration
In 2022, security researcher Youssef Sammouda disclosed a critical OAuth vulnerability in Facebook's login flow that allowed account takeover at scale. The root cause: a redirect_uri validation bypass that let an attacker redirect authorization codes to an attacker-controlled endpoint. Facebook awarded a $44,625 bug bounty. The vulnerability class — improper redirect URI validation — is consistently listed in OWASP's testing guide for OAuth weaknesses as one of the most common and most severe OAuth implementation errors.
OAuth 2.0 is simultaneously the most widely deployed authorization protocol on the web and one of the most frequently misconfigured. Per the Portswigger Web Security Academy, OAuth vulnerabilities appear in their top 10 web security topics by search volume, indicating how commonly developers encounter — and misunderstand — OAuth implementation details.
This article explains OAuth 2.0 from the ground up: the problem it solves, how each grant type works mechanically, where the security traps are, and what changed in OAuth 2.1. No hand-waving — actual request/response flows with real header shapes.
Key Takeaways
- ▸OAuth 2.0 is an authorization framework, not authentication. It controls what an app can access — not who the user is. OpenID Connect (OIDC) adds the identity layer on top.
- ▸The Authorization Code + PKCE flow is the correct choice for almost all user-facing apps in 2026 — web apps, SPAs, and mobile apps. PKCE is now mandatory in the OAuth 2.1 draft.
- ▸The implicit flow is formally deprecated (RFC 9700, 2024). It returned tokens in URL fragments, exposing them to browser history, referrer headers, and XSS. Never implement it for new applications.
- ▸Access tokens should be short-lived (15–60 min). Refresh tokens should be stored in HttpOnly cookies or secure storage — never localStorage, which is XSS-accessible.
- ▸Per Auth0's 2025 State of Identity report, 78% of enterprise applications use OAuth 2.0 or OIDC as their primary authorization mechanism, up from 61% in 2022.
The Problem OAuth Solves: The Password Anti-Pattern
Before OAuth, the common pattern for third-party integrations was to ask users for their credentials directly. Want to connect your email marketing tool to Gmail? Enter your Gmail password in the email tool. This was catastrophically bad:
- The third-party app had full access — not just what it needed
- Revoking access required changing your password, which affected all other services
- You had no visibility into what the app was actually doing with your credentials
- If the third-party app was breached, your primary account was compromised
OAuth 2.0 replaces credential-sharing with delegated authorization. Instead of your password, the third-party app receives a scoped, time-limited access token — a credential that represents specific permissions (read email, send email), not your identity. You can revoke that token at any time from your account settings without affecting your password or other connected apps.
This is the conceptual foundation. Everything else in OAuth 2.0 — grant types, token endpoints, redirect URIs — is implementation machinery for delivering this delegation securely.
The Four OAuth Roles (RFC 6749 §1.1)
OAuth 2.0 defines four parties, each with a distinct role in the authorization flow:
| Role | Definition | Real-World Example |
|---|---|---|
| Resource Owner | The user who owns the data and grants access | You, authorizing a photo app to read your Google Photos |
| Client | The application requesting access on behalf of the resource owner | The photo printing app |
| Authorization Server | Issues tokens after authenticating the user and obtaining consent | accounts.google.com |
| Resource Server | Hosts the protected resources; validates access tokens on each request | photos.googleapis.com |
In practice, the authorization server and resource server are often operated by the same organization (Google runs both accounts.google.com and *.googleapis.com), but the spec defines them as logically separate to allow for different trust relationships.
Public vs. Confidential Clients
OAuth 2.0 distinguishes between two client types based on whether they can keep a secret:
- Confidential clients can securely store a
client_secret— traditional server-side web apps where the secret lives in environment variables on the server, never exposed to users. - Public clients cannot keep secrets — SPAs (JavaScript runs in the browser), mobile apps (APKs can be decompiled), and desktop apps. Public clients use PKCE instead of client secrets to prove their identity.
This distinction drives which grant types are appropriate and how strictly the authorization server should validate the client. OAuth 2.1 formalizes the distinction and removes all flows that assumed public clients could be trusted with secrets.
OAuth 2.0 Grant Types: A Complete Comparison
A "grant type" defines how a client obtains an access token. OAuth 2.0 defined four in RFC 6749; subsequent RFCs added more. In 2026, two are dominant and two are deprecated:
| Grant Type | Use Case | Status (2026) | RFC |
|---|---|---|---|
| Authorization Code + PKCE | All user-facing apps (web, SPA, mobile) | ✓ Recommended | 6749 + 7636 |
| Client Credentials | Machine-to-machine, no user involved | ✓ Recommended | 6749 |
| Device Authorization | TVs, CLIs, limited-input devices | ✓ Recommended | 8628 |
| Implicit | Originally SPAs (pre-PKCE) | ✗ Deprecated (RFC 9700) | 6749 |
| Resource Owner Password | Direct credential submission (legacy) | ✗ Deprecated (OAuth 2.1) | 6749 |
Authorization Code + PKCE: The Full Request-by-Request Flow
This is the flow you should implement for any application that has human users. Here is every request and response, with real header shapes.
Step 1: Generate PKCE Parameters
Before the user touches anything, the client generates two values:
// Generate a cryptographically random code_verifier (43–128 chars, URL-safe)
const codeVerifier = base64URLEncode(crypto.getRandomValues(new Uint8Array(32)))
// e.g.: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
// Hash it with SHA-256 to create the code_challenge
const codeChallenge = base64URLEncode(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))
)
// e.g.: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
// Store codeVerifier in sessionStorage (needed for step 3)
sessionStorage.setItem('pkce_verifier', codeVerifier)The code_verifier is a secret the client holds. The code_challenge is the SHA-256 hash of it — safe to send to the authorization server because it's a one-way hash. This is the mechanism: the auth server stores the challenge, and later verifies the verifier matches it.
Step 2: Authorization Request (Browser Redirect)
The client redirects the user's browser to the authorization server with the code challenge:
GET https://auth.example.com/oauth/authorize ?response_type=code ← always "code" for this flow &client_id=app_client_id ← identifies your app &redirect_uri=https://app.example.com/callback ← MUST match exactly &scope=read:photos write:albums ← space-separated permissions &state=xyzABC123 ← CSRF protection token &code_challenge=E9Melho... ← SHA-256(code_verifier), base64url-encoded &code_challenge_method=S256 ← always S256 (SHA-256)
The state parameter is your CSRF token — generate it randomly, store it in session storage, and verify it matches when the callback arrives. Per OWASP's OAuth security testing guide, missing state validation is the second most common OAuth implementation vulnerability after redirect URI bypass.
Step 3: User Authenticates and Consents
The authorization server handles everything at this step: it presents a login form if needed, shows a consent screen listing the requested scopes, and upon approval redirects back to the client with an authorization code:
GET https://app.example.com/callback ?code=SplxlOBeZQQYbYS6WxSbIA ← short-lived authorization code (5–10 min) &state=xyzABC123 ← verify this matches what you sent
The authorization code is single-use and short-lived. It is not an access token — it cannot be used to call APIs. Its only purpose is to be exchanged for tokens in the next step.
Step 4: Token Exchange (Server-to-Server)
This is where PKCE proves its value. The client POSTs to the token endpoint from its server (not the browser), sending the authorization code and the original code_verifier:
POST https://auth.example.com/oauth/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=SplxlOBeZQQYbYS6WxSbIA &redirect_uri=https://app.example.com/callback ← must match step 2 &client_id=app_client_id &client_secret=super_secret_value ← confidential clients only &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r ← proves you initiated the flow
The authorization server re-hashes the code_verifier and compares it to the code_challenge stored from step 2. If they match, it issues tokens:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "8xLOxBtZp8",
"scope": "read:photos write:albums"
}Step 5: Calling Protected APIs
GET https://api.example.com/photos Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... # The resource server validates the token on every request: # 1. Verify the JWT signature using the auth server's public key # 2. Check exp claim (not expired) # 3. Check iss claim (from the expected issuer) # 4. Check aud claim (for this resource server) # 5. Check scope claim contains "read:photos"
Client Credentials Flow: Machine-to-Machine Authentication
When there's no user involved — a background job calling an internal API, a microservice authenticating to another service, a CI/CD pipeline — use the Client Credentials flow. It's the simplest OAuth flow: one POST, one token.
POST https://auth.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)
grant_type=client_credentials
&scope=read:internal-api
# Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600
// No refresh_token — just re-request when expired
}Client credentials tokens expire and must be refreshed. Cache them close to their expiry — a common pattern is to subtract 60 seconds from expires_in and invalidate the cached token that many seconds before expiry. Never hardcode client secrets; load them from environment variables or a secrets manager like AWS Secrets Manager or HashiCorp Vault.
Access Tokens, Refresh Tokens, and ID Tokens: What Each One Is For
Access Tokens
Access tokens represent authorization to access specific resources. They are sent with every API request in the Authorization: Bearer header. They should be:
- Short-lived — 15 minutes to 1 hour is typical. Short lifetimes limit the damage window if a token is stolen.
- Scoped — only contain the permissions actually granted. The resource server validates scopes on every request.
- JWTs or opaque strings — JWTs let resource servers validate tokens locally (no auth server round-trip). Opaque tokens require the resource server to call the auth server's introspection endpoint.
Access tokens are typically JWTs. To understand the JWT format in depth, see BytePane's JWT tokens guide.
Refresh Tokens
Refresh tokens are long-lived credentials that can obtain new access tokens without user interaction. They are not sent to resource servers — only to the authorization server's token endpoint.
POST https://auth.example.com/oauth/token Content-Type: application/x-www-form-urlencoded grant_type=refresh_token &refresh_token=8xLOxBtZp8 &client_id=app_client_id &client_secret=secret_value ← confidential clients only
Storage rule: Never store refresh tokens in localStorage (XSS-accessible). Server-side apps should store them in the session or database. SPAs should use HttpOnly, Secure, SameSite=Strict cookies. The OAuth 2.1 draft requires refresh tokens for public clients to be either sender-constrained (bound to the client's DPoP key) or one-time-use with rotation on each use.
ID Tokens (OpenID Connect)
ID tokens are issued by OIDC, not OAuth 2.0 directly. They are JWTs containing claims about the authenticated user — sub (user identifier), email, name, iat, exp. They are meant for the client to read (to know who the user is), not for sending to resource servers. Do not confuse them with access tokens.
The Five Most Exploited OAuth Vulnerabilities
Per PortSwigger Web Security Academy's OAuth testing guide and OWASP's OAuth Weaknesses checklist, these are the implementation errors that appear most frequently in bug bounty reports and penetration tests:
1. Lax Redirect URI Validation
Authorization servers that match redirect URIs by prefix (https://app.com matches https://app.com.evil.net) or allow wildcards. This lets attackers redirect authorization codes to attacker-controlled endpoints.
Fix: Require exact redirect URI matching. Pre-register all valid redirect URIs. Reject any request where the redirect_uri doesn't exactly match a pre-registered value.
2. Missing State Parameter (CSRF)
If the client doesn't use the state parameter, an attacker can craft a malicious authorization request and trick the victim into completing it — resulting in the attacker's account being linked to the victim's session.
Fix: Always generate a cryptographically random state, store it in session, verify it on callback. Reject any callback where state doesn't match.
3. Access Token Stored in localStorage
localStorage is accessible to all JavaScript on the page. A single XSS vulnerability allows an attacker to steal tokens and make authenticated requests until they expire.
Fix: For SPAs, keep tokens in memory only. Use a secure backend-for-frontend (BFF) pattern where token storage happens server-side. If you must persist tokens, use HttpOnly cookies.
4. Insufficient Scope Validation
Resource servers that check token validity but not token scope. An access token with read:photos scope should not be accepted by an endpoint that requires write:admin.
Fix: Validate both token authenticity AND required scopes on every protected endpoint. Never trust the presence of a valid token as sufficient authorization.
5. Authorization Code Without PKCE on Public Clients
On mobile and SPA clients, authorization codes can be intercepted by malicious apps registered for the same redirect URI scheme. Without PKCE, the intercepted code can be exchanged for tokens.
Fix: PKCE is mandatory for all public clients. PKCE is required in OAuth 2.1 for all clients, including confidential ones.
OAuth 2.1: What Changes (And What Doesn't)
OAuth 2.1 is an opinionated simplification of OAuth 2.0, consolidating the core spec with security best practices from a decade of deployment. It does not introduce new flows — it removes the dangerous ones and mandates the correct behaviors.
Per Descope's OAuth 2.1 vs 2.0 comparison (2025) and the draft specification, the changes are:
- PKCE is mandatory for all authorization code flows — both public and confidential clients. Previously optional for confidential clients.
- Implicit grant removed — use Authorization Code + PKCE instead.
- Resource Owner Password Credentials (ROPC) removed — it required sharing the user's password with the client, defeating OAuth's core purpose.
- Redirect URIs must match exactly — no partial matching, no wildcards.
- Refresh tokens must be sender-constrained or one-time-use for public clients — prevents stolen refresh tokens from being usable from a different client.
- Bearer tokens in URI query strings are prohibited — tokens only via Authorization header or request body.
If you implement OAuth 2.1 practices today, your OAuth 2.0 implementation is effectively OAuth 2.1-compatible. There is no breaking change for clients following current best practices — the changes only remove things that shouldn't have been there.
OAuth 2.0 vs. OpenID Connect vs. SAML: When to Use Which
| Protocol | Type | Token Format | Best For |
|---|---|---|---|
| OAuth 2.0 | Authorization | JWT or opaque | API access delegation |
| OpenID Connect | Authentication + Authorization | JWT (ID token) | "Login with Google/GitHub", SSO for modern apps |
| SAML 2.0 | Authentication + Authorization | XML assertions | Enterprise SSO, ADFS, legacy systems |
For new applications, prefer OIDC over SAML — it's simpler, uses JSON instead of XML, works over standard HTTPS, and has broad library support. SAML remains dominant in enterprise environments because of legacy Active Directory integration and compliance requirements (HIPAA, FedRAMP) that specify it by name.
Both OIDC and SAML build on top of OAuth-style flows. Understanding OAuth 2.0 deeply makes both protocols easier to reason about. For the JWT format that OAuth access tokens and OIDC ID tokens use, see BytePane's JWT vs session cookies comparison.
Frequently Asked Questions
What is OAuth 2.0 and what does it do?
OAuth 2.0 is an authorization framework (RFC 6749) that lets applications request limited access to a user's resources without receiving their credentials. It issues scoped, time-limited access tokens representing specific permissions. Classic example: letting a photo printing app read your Google Photos without giving it your Google password.
What is the difference between OAuth and OpenID Connect?
OAuth 2.0 is authorization only — it grants access to resources but says nothing about who the user is. OpenID Connect (OIDC) adds an identity layer on top with ID tokens (JWTs containing user claims), a /userinfo endpoint, and standardized scopes like "openid profile email". Use OIDC for "Login with X." Use OAuth alone for API access delegation.
Why was the OAuth 2.0 implicit flow deprecated?
The implicit flow returned access tokens in URL fragments (#access_token=...). This exposed tokens to browser history, server logs, and referrer headers. RFC 9700 formally deprecated it in 2024. SPAs should use Authorization Code + PKCE instead — equally browser-compatible but tokens never appear in URLs.
What is PKCE and why is it required?
PKCE (RFC 7636) prevents authorization code interception attacks. The client generates a code_verifier, hashes it to a code_challenge, and sends the challenge with the auth request. When exchanging the code for tokens, it sends the verifier — proving it initiated the flow. Without PKCE, a malicious app could intercept the code and exchange it for tokens.
What is the difference between access tokens and refresh tokens?
Access tokens are short-lived (15 min–1 hour) and sent with every API request. Refresh tokens are long-lived (days to months) and used only to get new access tokens when the current one expires. Store refresh tokens in HttpOnly cookies or server-side sessions — never in localStorage which is XSS-accessible.
What changed between OAuth 2.0 and OAuth 2.1?
OAuth 2.1 mandates PKCE for all flows, removes the implicit grant, removes the Resource Owner Password Credentials grant, requires exact redirect URI matching, and requires refresh tokens for public clients to be sender-constrained or one-time-use. It's a consolidation of best practices, not a redesign.
When should I use the Client Credentials flow?
Use Client Credentials for machine-to-machine communication — no human user involved. Background workers, microservice-to-microservice auth, CI/CD pipelines deploying to APIs. The client sends client_id and client_secret directly to the token endpoint and gets an access token. No redirect, no browser, no user consent.
Decode OAuth Access Tokens (JWTs)
Paste a JWT access token to inspect its header, payload claims, and signature. Useful for debugging OAuth flows — no server-side processing, runs entirely in your browser.
Open JSON Formatter / Token Inspector →