Why OAuth 2.1 Exists
OAuth 2.0 was published as RFC 6749 in 2012. In the fourteen years since, the security landscape has changed dramatically. The original spec included grant types that are now considered dangerous. Security best practices that emerged later (like PKCE) were published as separate extensions, leaving developers to piece together which RFCs to follow and which to ignore.OAuth 2.1 fixes this. It's not a revolutionary new protocol -- it's a consolidation. It takes the original OAuth 2.0 spec, removes the dangerous parts, incorporates the security best practices that emerged as separate RFCs, and packages everything into a single, coherent specification.
If you've been following OAuth security best practices (BCP), you're already most of the way there. If you haven't -- and many production systems haven't -- this is your migration guide.
For a deep dive into the original protocol, OAuth 2 in Action remains the definitive reference.
What Changed: The Summary
Here's the short version:
| Change | OAuth 2.0 | OAuth 2.1 |
|---|---|---|
| PKCE | Optional extension | Required for all clients |
| Implicit grant | Allowed | Removed |
| Resource Owner Password grant | Allowed | Removed |
| Redirect URI matching | Loose matching allowed | Exact string matching required |
| Refresh token rotation | Not required | Required (or sender-constrained) |
| Bearer tokens in URI query | Allowed | Prohibited |
| Client credentials grant | Allowed | Still allowed |
| Authorization code grant | Allowed | Still allowed (with PKCE) |
Let's dig into each change.
PKCE Is Now Required -- For Everyone
This is the biggest practical change. PKCE (Proof Key for Code Exchange, pronounced "pixy") was originally designed for public clients (mobile apps, SPAs) that can't securely store a client secret. OAuth 2.1 makes it mandatory for all clients, including confidential (server-side) clients.
Why?
Even confidential clients benefit from PKCE. It protects against authorization code injection attacks, where an attacker captures an authorization code and attempts to exchange it at the token endpoint. With PKCE, the authorization code is cryptographically bound to the client that initiated the request.
How PKCE Works
PKCE Implementation Code
Here's the complete PKCE flow in TypeScript:
// Step 1: Generate PKCE values
function generatePKCE(): { verifier: string; challenge: string } {
// Generate a random 32-byte code verifier
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const verifier = base64URLEncode(array);
// Create the code challenge (SHA-256 hash of verifier)
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
// Note: in a real app, this is async
return crypto.subtle.digest('SHA-256', data).then(hash => ({
verifier,
challenge: base64URLEncode(new Uint8Array(hash)),
}));
}
function base64URLEncode(buffer: Uint8Array): string {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
// Step 2: Build the authorization URL
function buildAuthURL(codeChallenge: string): string {
const params = new URLSearchParams({
response_type: 'code',
client_id: 'my-app',
redirect_uri: 'https://app.example.com/callback',
scope: 'openid profile email',
state: generateRandomState(),
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return https://auth.example.com/authorize?${params};
}
// Step 3: Exchange code for tokens (on callback)
async function exchangeCode(
code: string,
codeVerifier: string
): Promise<TokenResponse> {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://app.example.com/callback',
client_id: 'my-app',
code_verifier: codeVerifier, // The PKCE proof
}),
});
return response.json();
}
Deprecated Grants: What You Need to Remove
The Implicit Grant Is Gone
The implicit grant (response_type=token) returned access tokens directly in the URL fragment. This was designed for JavaScript SPAs in 2012 when CORS wasn't widely supported and SPAs couldn't make cross-origin POST requests to a token endpoint.
- Tokens in URLs are logged in browser history, server logs, and referrer headers
- No refresh token support (tokens had to be short-lived or re-fetched)
- No PKCE protection possible (no code exchange step)
- Token leakage through open redirectors
// OLD (Implicit - DO NOT USE)
// Response: https://app.example.com/callback#access_token=xyz&token_type=bearer
// NEW (Authorization Code + PKCE)
// Response: https://app.example.com/callback?code=abc&state=123
// Then exchange 'code' for tokens via POST to token endpoint
The Resource Owner Password Credentials Grant Is Gone
The ROPC grant (grant_type=password) sent the user's username and password directly to the client application, which then exchanged them for tokens. This was always an anti-pattern because it required the user to trust the client with their credentials.
- The client handles raw credentials (defeats the purpose of OAuth)
- No support for MFA or other interactive authentication flows
- Trains users to enter their credentials in non-IdP interfaces
- Incompatible with phishing-resistant authentication
Exact Redirect URI Matching
OAuth 2.0 allowed authorization servers to do partial or pattern-based redirect URI matching. This opened the door to open redirect attacks where an attacker could construct a redirect URI that matched the pattern but pointed to their server.
OAuth 2.1 requires exact string matching. The redirect URI in the authorization request must exactly match a registered redirect URI -- no wildcards, no partial matches, no query parameter variations.
# Registered: https://app.example.com/callback
ALLOWED:
https://app.example.com/callback
REJECTED (query param variation):
https://app.example.com/callback?next=/dashboard
REJECTED (path variation):
https://app.example.com/callback/
REJECTED (subdomain variation):
https://staging.app.example.com/callback
This means you need to register every callback URL your application uses, including different environments (staging, production, etc.).
Refresh Token Security
OAuth 2.1 tightens refresh token handling in two ways:
1. Refresh Token Rotation
When a client uses a refresh token, the authorization server must issue a new refresh token and invalidate the old one. If an attacker steals a refresh token and the legitimate client also tries to use it, the server detects the reuse and revokes the entire token family.
// Token refresh with rotation
async function refreshTokens(refreshToken: string): Promise<TokenResponse> {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: 'my-app',
}),
});
const tokens = await response.json();
// IMPORTANT: Store the NEW refresh token, discard the old one
// The old refresh token is now invalid
await secureStore.set('refresh_token', tokens.refresh_token);
await secureStore.set('access_token', tokens.access_token);
return tokens;
}
2. Sender-Constrained Tokens (Alternative)
As an alternative to rotation, authorization servers can issue sender-constrained refresh tokens using DPoP (Demonstrating Proof of Possession) or mTLS. These bind the token to the client's cryptographic key, making stolen tokens useless without the corresponding private key.
Bearer Tokens in Query Parameters: Prohibited
OAuth 2.0 allowed access tokens to be sent as query parameters:
GET /api/data?access_token=eyJhbGci... HTTP/1.1
OAuth 2.1 prohibits this. Tokens must be sent in the Authorization header:
GET /api/data HTTP/1.1
Authorization: Bearer eyJhbGci...
Query parameters are logged by web servers, proxies, and CDNs. Putting tokens there is a significant leakage vector.
Migration Checklist
Here's a practical checklist for migrating from OAuth 2.0 to OAuth 2.1 compliance:
For Application Developers
- [ ] Add PKCE to all authorization code flows. Even if you're a confidential client.
- [ ] Remove any implicit grant usage. Switch to authorization code + PKCE.
- [ ] Remove any ROPC grant usage. Switch to authorization code flow.
- [ ] Stop sending tokens in query parameters. Use the
Authorizationheader exclusively. - [ ] Handle refresh token rotation. Always store the new refresh token from a refresh response.
- [ ] Register exact redirect URIs. No wildcards or patterns.
- [ ] Review your OAuth scopes. Use the principle of least privilege -- request only the scopes you need.
For Authorization Server Operators
- [ ] Require PKCE on all authorization code flows. Reject requests without
code_challenge. - [ ] Remove support for implicit grant. Return an error for
response_type=token. - [ ] Remove support for ROPC grant. Return an error for
grant_type=password. - [ ] Enforce exact redirect URI matching. No partial matches.
- [ ] Implement refresh token rotation. Issue new refresh tokens on each use, detect reuse.
- [ ] Reject tokens in query parameters. Only accept
Authorization: Bearerheader. - [ ] Review token lifetimes. Access tokens should be short-lived (5-15 minutes). Refresh tokens should have a reasonable expiry (hours to days, not months).
For API Resource Servers
- [ ] Validate JWTs properly. Verify signature, issuer, audience, expiration, and not-before claims.
- [ ] Reject tokens from query parameters. Only extract tokens from the
Authorizationheader. - [ ] Implement token introspection for opaque tokens if not using JWTs.
- [ ] Enforce scope-based access control. Ensure the token's scopes match the requested operation.
What About OpenID Connect?
OIDC (OpenID Connect) builds on top of OAuth 2.0 to add identity (authentication). The OAuth 2.1 changes apply to the OAuth layer that OIDC uses. Specifically:- OIDC's
response_type=id_token(the implicit flow for ID tokens) is not deprecated by OAuth 2.1. It's an OIDC-specific mechanism. - OIDC's
response_type=code(the authorization code flow) now requires PKCE. - The hybrid flows (
response_type=code id_token, etc.) should be avoided in favor of the pure authorization code flow with PKCE.
The Bigger Picture
OAuth 2.1 is not a dramatic overhaul. It's the security community saying: "We've learned a lot in 14 years. Here's the current best practice, in one document." The changes remove footguns and codify patterns that security-conscious developers have already been following.
If your applications are still using the implicit grant, ROPC, or authorization code without PKCE, OAuth 2.1 isn't introducing new complexity -- it's telling you that your current implementation has known vulnerabilities. Fix them.
For a comprehensive treatment of OAuth architecture, implementation patterns, and security considerations, OAuth 2 in Action covers the foundational concepts that OAuth 2.1 builds on. For the identity layer, see our glossary entries on token-based authentication and the authorization code flow.