Skip to main content
DI
oauthstandardsdevelopers

OAuth 2.1: What Every Developer Needs to Know

OAuth 2.1 consolidates years of security best practices into a single specification. Here's what changed from OAuth 2.0, what's been deprecated, and a practical migration checklist with code examples for the PKCE-required authorization code flow.

Deepak GuptaFebruary 28, 202613 min read
Share:

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:

ChangeOAuth 2.0OAuth 2.1
PKCEOptional extensionRequired for all clients
Implicit grantAllowedRemoved
Resource Owner Password grantAllowedRemoved
Redirect URI matchingLoose matching allowedExact string matching required
Refresh token rotationNot requiredRequired (or sender-constrained)
Bearer tokens in URI queryAllowedProhibited
Client credentials grantAllowedStill allowed
Authorization code grantAllowedStill 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

OAuth 2.1 Authorization Code Flow with PKCE {/* Actors */} Client App Auth Server Resource API {/* Vertical lines */} {/* Step 1: Generate PKCE */} 1. Generate PKCE code_verifier (random) code_challenge = SHA256(verifier) {/* Step 2: Auth request */} 2. Authorization Request + code_challenge + code_challenge_method=S256 {/* Step 3: User authenticates */} 3. User logs in + consents {/* Step 4: Auth code redirect */} 4. Redirect with authorization_code code + state (exact redirect_uri match) {/* Step 5: Token request */} 5. Token Request + code_verifier (the PKCE proof!) {/* Step 6: Server verifies */} 6. Server verifies: SHA256(code_verifier) == code_challenge {/* Step 7: Tokens returned */} 7. Access Token + Refresh Token (ID token too, if OIDC) {/* Step 8: API call */} 8. API Request + Access Token (Authorization: Bearer) {/* Step 9: Response */} 9. Protected Resource {/* PKCE highlight box */} Why PKCE Matters Even if an attacker intercepts the authorization_code (step 4), they cannot exchange it for tokens because they don't have the code_verifier. The code_verifier never travels through the browser redirect. PKCE binds the authorization request to the token request cryptographically.

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.

Why it was removed:
  • 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
What to use instead: The authorization code flow with PKCE. Modern browsers fully support CORS, so SPAs can make token endpoint requests directly.
// 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.

Why it was removed:
  • 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
What to use instead: The authorization code flow. If you need a "native app" experience, use the authorization code flow with a system browser or in-app browser tab (ASWebAuthenticationSession on iOS, Custom Tabs on Android).

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 Authorization header 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: Bearer header.
  • [ ] 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 Authorization header.
  • [ ] 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.

Enjoyed this article?

Subscribe for more expert insights on digital identity, IAM, and security.