- Published on
OAuth 2.1 Migration Guide — Designing Authentication in the Era of Mandatory PKCE
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction
- The Problems with OAuth 2.0 — Why 2.1 Was Necessary
- The Core Changes in OAuth 2.1
- PKCE in Detail
- Hardened Refresh Token Handling
- Recommended Patterns per Client Type
- Migration Checklist for Existing Apps
- OAuth 2.1 in the AI Agent Era — the MCP Context
- Operational Best Practices
- Conclusion
- References
Introduction
It has been 14 years since OAuth 2.0 (RFC 6749) was published. In that time, OAuth 2.0 became the de facto standard for virtually all API authorization — and, at the same time, the root cause of countless security incidents. Access tokens leaked through URL fragments in the Implicit flow, authorization codes were stolen via interception attacks, and loosely matched redirect URIs funneled tokens to attacker-controlled servers, over and over again.
To address these problems, the IETF OAuth working group kept updating its security guidance, culminating in January 2025 with RFC 9700, the OAuth 2.0 Security Best Current Practice. And the document that consolidates all of these lessons into a single specification is the OAuth 2.1 draft.
As of 2026, OAuth 2.1 is still an IETF draft, but the industry already treats it as the de facto standard. Its position became even stronger with the rise of AI agents. The authorization spec of MCP (Model Context Protocol) is written on top of OAuth 2.1, and Keycloak 26.6 added experimental support for the OAuth Client ID Metadata Document (CIMD), allowing it to act as an MCP authorization server. If you are building a new system, there is no reason whatsoever to repeat OAuth 2.0 legacy patterns.
This article covers the problems of OAuth 2.0, the changes that OAuth 2.1 consolidates, how PKCE works in detail, and a concrete checklist for migrating existing applications to OAuth 2.1 compatibility.
The Problems with OAuth 2.0 — Why 2.1 Was Necessary
The spec was scattered
Implementing OAuth 2.0 "properly" required reading far too many documents.
| Document | Content | Published |
|---|---|---|
| RFC 6749 | OAuth 2.0 core framework | 2012 |
| RFC 6750 | Bearer Token usage | 2012 |
| RFC 7636 | PKCE | 2015 |
| RFC 8252 | Native apps BCP | 2017 |
| RFC 9700 | Security BCP (formerly draft-ietf-oauth-security-topics) | 2025 |
The problem was that far too many systems were implemented after reading only RFC 6749. The Implicit flow, the password grant, and wildcard redirect URIs — all legal by 2012 standards — remained in production as-is. OAuth 2.1 consolidates the three essential documents — RFC 6749 + RFC 7636 (PKCE) + RFC 9700 (Security BCP) — into one, and deletes the dangerous options at the spec level.
Concrete attack scenarios
The attacks that actually recurred throughout the OAuth 2.0 era can be summarized as follows.
Attack 1: Implicit flow token leakage
- access_token is delivered in the URL fragment (#access_token=...)
- The token persists in browser history, Referer headers, proxy logs
- Malicious scripts read location.hash and steal the token
Attack 2: Authorization Code Interception (mobile)
- A malicious app registers the same custom URL scheme
(myapp://callback) as the legitimate app
- The OS delivers the authorization code to the malicious app
- The attacker exchanges the code for tokens
(impossible to prevent without PKCE)
Attack 3: Redirect URI partial-matching abuse
- Registered: https://app.example.com/*
- Attack: https://app.example.com/redirect?url=https://evil.com
- Codes/tokens reach the attacker via an open redirector
Attack 4: Account-linking manipulation via CSRF
- Without the state parameter, an attacker can inject their own
authorization response into the victim's session
Attack 5: Indefinite use of a stolen refresh token
- Without rotation, a stolen refresh token stays valid
until expiry (or indefinitely)
Attacks 1 and 2 are design flaws of the flows themselves; 3 through 5 are areas where the spec stopped at "recommendation". OAuth 2.1 either upgrades all of these to MUST or removes the feature entirely.
The Core Changes in OAuth 2.1
The changes in the OAuth 2.1 draft can be summarized in a single table.
| Item | OAuth 2.0 | OAuth 2.1 |
|---|---|---|
| PKCE | Optional (RFC 7636, mostly mobile) | Mandatory for every authorization code flow |
| Implicit grant | Allowed | Removed |
| Resource Owner Password Credentials | Allowed | Removed |
| Redirect URI matching | Implementation-defined (partial matching common) | Exact string comparison mandatory |
| Refresh tokens (public clients) | No constraints | Sender-constrained or rotation mandatory |
| Bearer token in query string | Allowed | Forbidden |
| state parameter | Recommended | CSRF defense absorbed by PKCE; state is for app state |
Let us examine each in detail.
Removing the Implicit grant — why, and what replaces it
The Implicit flow was a compromise created in 2012 because of browser limitations at the time (no CORS support). It returned the access token directly in the authorization response as a fragment, without calling the token endpoint.
[Implicit flow - removed]
Browser ──> /authorize?response_type=token ──> IdP
Browser <── 302 Location: https://app/#access_token=eyJ... <── IdP
└─ The token is exposed right in the URL
The problems are clear.
- The token is exposed in the URL and leaks through history, logs, and Referer headers.
- Since the token endpoint is never called, client authentication is impossible.
- Because the response goes directly to the client, neither token rotation nor sender-constraining can be applied.
The replacement is simple. Every browser now supports CORS, so SPAs can use the authorization code flow + PKCE. Tokens are delivered in the POST response body from the token endpoint, and only a single-use authorization code appears briefly in the URL.
Removing ROPC (the password grant) — why, and what replaces it
The Resource Owner Password Credentials grant has the client application collect the user's username and password directly and send them to the token endpoint.
POST /token HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=password&username=alice&password=hunter2&client_id=legacy-app
This approach negates the very reason OAuth exists. OAuth was created precisely so that passwords would never be handed to third-party apps — and ROPC does exactly the opposite. It also makes all of the following impossible:
- Applying stronger authentication such as MFA and passkeys
- IdP-driven risk-based authentication
- Phishing resistance — if the app is fake, the password is simply harvested
- SSO — the password flows through individual apps rather than the IdP
The replacement pattern depends on the use case.
| Why ROPC was used | Replacement pattern |
|---|---|
| Native login UI in a first-party mobile app | Authorization code + PKCE (system browser / Custom Tab) |
| Server-to-server communication, batch jobs | client_credentials grant |
| CLI tool login | Device authorization grant (RFC 8628) or PKCE + loopback redirect |
| Test automation | Dedicated test client_credentials or pre-issued tokens |
Redirect URI exact matching
OAuth 2.1 requires redirect URIs to be validated using simple string comparison. Wildcards, partial matching, and regex matching are all forbidden.
Registered URI: https://app.example.com/callback
Requested URI Verdict
https://app.example.com/callback allowed
https://app.example.com/callback/ rejected (trailing slash)
https://app.example.com/callback?next=/home rejected (added query)
https://app.example.com.evil.com/callback rejected
http://app.example.com/callback rejected (different scheme)
A common anti-pattern in Keycloak is putting wildcards into the Valid Redirect URIs field.
Bad: https://app.example.com/* ← token leakage when combined with an open redirector
Bad: * ← never, ever
Good: https://app.example.com/auth/callback (list every required path explicitly)
The only exception is the loopback redirect (127.0.0.1) for native apps, where only the port number may vary (RFC 8252).
PKCE in Detail
PKCE (Proof Key for Code Exchange, RFC 7636) is the heart of OAuth 2.1. It is pronounced "pixy".
The core idea
It is a mechanism to prove that the party who requested the authorization code and the party exchanging it for tokens are the same. The client generates a one-time secret (code_verifier) for every authorization request and sends only its hash (code_challenge) up front. When exchanging the code, the client submits the original, and the server recomputes the hash and compares.
1) Client: generate code_verifier
- Random string of 43-128 characters (A-Z, a-z, 0-9, -._~)
- Use a CSPRNG with at least 256 bits of entropy
2) Client: compute code_challenge
code_challenge = BASE64URL( SHA256( code_verifier ) )
3) Authorization request (front channel)
GET /authorize?response_type=code
&client_id=spa-client
&redirect_uri=https://app.example.com/callback
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&scope=openid profile
&state=af0ifjsldkj
4) IdP: stores code_challenge alongside the authorization code, issues the code
5) Token request (back channel)
POST /token
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https://app.example.com/callback
&client_id=spa-client
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
6) IdP: BASE64URL(SHA256(received code_verifier)) == stored code_challenge ?
- Match: issue tokens
- Mismatch: invalid_grant error
Even if an attacker intercepts the authorization code, they cannot exchange it for tokens because they do not know the code_verifier. The verifier is never exposed on the front channel (browser redirects) and travels only over the TLS-protected back channel.
code_challenge_method: S256 vs plain
| Method | Formula | Notes |
|---|---|---|
| S256 | BASE64URL(SHA256(verifier)) | Mandatory to support, always use this |
| plain | the verifier itself | Defeated if the authorization request leaks; do not use |
In OAuth 2.1, plain is allowed only "when S256 is technically unavailable" — and in practice such an environment does not exist.
The downgrade attack and its defense
PKCE itself has a downgrade attack vector, covered in detail by RFC 9700.
Scenario: PKCE downgrade
1. The attacker tries to strip the code_challenge from the victim's
authorization request, or swap it to plain
2. If the server treats PKCE as "optional", it accepts requests
without a challenge
3. The attacker exchanges the intercepted code without a verifier
Defense (server side):
- If the authorization request contained a code_challenge, the token
request MUST contain a code_verifier (reject otherwise)
- If the authorization request had no code_challenge but the token
request carries a code_verifier, reject
- Enforce PKCE via client policy (Keycloak:
Advanced Settings > PKCE Code Challenge Method = S256)
In Keycloak, PKCE can be enforced per client.
# Enforce PKCE S256 via kcadm
kcadm.sh update clients/CLIENT-UUID -r myrealm \
-s 'attributes."pkce.code.challenge.method"=S256'
Code examples — generating verifier/challenge
// SPA (browser) - Web Crypto API
function base64url(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function createPkcePair() {
const random = crypto.getRandomValues(new Uint8Array(32));
const verifier = base64url(random.buffer); // 43 chars
const digest = await crypto.subtle.digest(
'SHA-256', new TextEncoder().encode(verifier)
);
const challenge = base64url(digest);
return { verifier, challenge };
}
// Java - when implementing directly without Spring Security
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
public final class Pkce {
private static final SecureRandom RANDOM = new SecureRandom();
private static final Base64.Encoder B64 =
Base64.getUrlEncoder().withoutPadding();
public static String newVerifier() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return B64.encodeToString(bytes);
}
public static String challengeOf(String verifier) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(verifier.getBytes("US-ASCII"));
return B64.encodeToString(digest);
}
}
If you use Spring Security, there is no need to implement it yourself — a single configuration line does the job.
# application.yml - Spring Security 6.x applies PKCE automatically for public clients
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: my-spa
authorization-grant-type: authorization_code
redirect-uri: "http://localhost:8080/login/oauth2/code/keycloak"
scope: openid,profile
client-authentication-method: none # public client → PKCE automatic
provider:
keycloak:
issuer-uri: https://idp.example.com/realms/myrealm
Hardened Refresh Token Handling
OAuth 2.1 requires one of the following for refresh tokens issued to public clients (SPAs, mobile apps):
- Sender-constrained: bind the token to a specific client key via DPoP (RFC 9449) or mTLS (RFC 8705)
- Rotation: issue a new refresh token on every use and invalidate the previous one
The essence of rotation is reuse detection.
Normal flow:
RT1 used → AT2 + RT2 issued, RT1 invalidated
RT2 used → AT3 + RT3 issued, RT2 invalidated
Theft scenario:
Attacker steals RT1 → legitimate user uses RT1 → RT2 issued
Attacker tries to reuse RT1
→ Server: "RT1 was already used" = theft signal
→ Invalidate the entire token family (everything derived from RT1)
→ Force the user to log in again
Keycloak configures this at the realm level.
# Realm Settings > Sessions / Tokens
kcadm.sh update realms/myrealm \
-s revokeRefreshToken=true \
-s refreshTokenMaxReuse=0
Refresh token lifecycle design is covered in much more depth in a separate article (Refresh Token Rotation and Session Management).
Recommended Patterns per Client Type
SPA (browser only)
Recommended: Authorization Code + PKCE (public client)
or the BFF pattern (confidential client, no tokens in the browser)
┌─────────┐ 1. /authorize (+ code_challenge) ┌─────────┐
│ Browser │ ──────────────────────────────────> │ IdP │
│ (SPA) │ <── 2. 302 + code ───────────────── │ │
│ │ ── 3. /token (+ code_verifier) ───> │ │
│ │ <── 4. AT + RT (rotation applied) ─ │ │
└─────────┘ └─────────┘
- Store tokens in memory (JS variables) by default. localStorage is vulnerable to XSS.
- If you need stronger security, use a BFF (Backend-for-Frontend) that keeps tokens server-side and issues only an HttpOnly session cookie to the browser.
Mobile / desktop native apps
Recommended: Authorization Code + PKCE + system browser (RFC 8252)
- iOS: ASWebAuthenticationSession
- Android: Custom Tabs
- redirect: prefer HTTPS-based App Links / Universal Links
(more interception-resistant than custom schemes)
- Never embed login in a WebView (phishing + cookie isolation issues)
Server-side web apps
Recommended: Authorization Code + PKCE (confidential client)
- Client authentication via client_secret or private_key_jwt
- PKCE applies to confidential clients too (mandatory in OAuth 2.1)
→ defends against authorization code injection
- Session cookies: HttpOnly + Secure + SameSite=Lax or stricter
You might wonder why a confidential client needs PKCE. The client_secret proves that "the token request came from that client", but it does not prove that "this authorization code is the result of a request started in this session". The code injection attack — where an attacker injects their own authorization code into the victim's session — can only be stopped by PKCE (or the OIDC nonce).
Server-to-server (no user)
Recommended: client_credentials grant
- Authentication: private_key_jwt or mTLS > client_secret
- 2026 trend: integrate with workload identity (SPIFFE/SVID);
Keycloak 26.6 Federated client authentication lets Kubernetes
service account tokens be used for client authentication
Migration Checklist for Existing Apps
A checklist you can use directly in practice. Work through it in order.
Step 1 — Inventory
- List every OAuth client (export from the IdP admin console)
- Classify by grant type: implicit / password / authorization_code / client_credentials
- Check redirect URIs for wildcards or http (non-TLS)
- Identify authorization_code clients without PKCE
- Verify refresh token policy (rotation enabled? lifetimes?)
With Keycloak you can investigate like this.
# Find clients with implicit flow enabled
kcadm.sh get clients -r myrealm --fields clientId,implicitFlowEnabled \
| jq '.[] | select(.implicitFlowEnabled == true) | .clientId'
# Find clients with Direct Access Grants (ROPC) enabled
kcadm.sh get clients -r myrealm --fields clientId,directAccessGrantsEnabled \
| jq '.[] | select(.directAccessGrantsEnabled == true) | .clientId'
Step 2 — Remove the highest-risk items first
- Convert implicit-flow clients to code + PKCE (usually just a library swap)
- Convert ROPC clients to code+PKCE / client_credentials / device flow depending on use case
- Replace wildcard redirect URIs with explicit lists
- Remove API calls that pass access tokens in query strings (use the Authorization header)
Step 3 — Apply PKCE and rotation
- Enforce PKCE S256 for every authorization_code client (IdP policy)
- Enable refresh token rotation + reuse detection for public clients
- Standardize on certified client libraries (AppAuth, oidc-client-ts, Spring Security, etc.)
Step 4 — Verify
- Test that authorization requests without a challenge are rejected
- Test that token exchange fails with a wrong verifier
- Test that redirect URI variations (trailing slash, added query) are rejected
- Test that family invalidation fires on refresh token reuse
# Verify PKCE enforcement: authorization request without challenge → expect an error
curl -s -o /dev/null -w "%{http_code}\n" \
"https://idp.example.com/realms/myrealm/protocol/openid-connect/auth?client_id=my-spa&response_type=code&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback"
Common pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
| Storing the verifier in localStorage | Cross-tab conflicts, XSS exposure | sessionStorage or memory |
| Code reuse | Intermittent invalid_grant | Idempotent callback handler; beware React StrictMode double-invocation |
| Clock skew | Tokens treated as instantly expired | NTP sync, configure clock skew tolerance |
| Proxy logging query strings | Authorization codes end up in logs | Code lifetime under 60 s + verify single-use guarantee |
| Tests break after removing ROPC | CI authentication failures | Separate client_credentials client for tests |
OAuth 2.1 in the AI Agent Era — the MCP Context
The decisive moment that made OAuth 2.1 the de facto standard in 2025-2026 was the rise of AI agents.
MCP (Model Context Protocol) is the standard protocol for LLM agents to access external tool/data servers — and its authorization spec is written on top of OAuth 2.1. An MCP server acts as an OAuth 2.1 resource server, and an MCP client (the agent host) acts as an OAuth 2.1 client. PKCE is of course mandatory, and dynamic client registration (RFC 7591) plus protected resource metadata (RFC 9728) are used heavily.
This is where Keycloak 26.6's experimental OAuth Client ID Metadata Document (CIMD) support gets interesting. Clients that are hard to pre-register — like agents — use the URL of their own metadata document as the client_id, which enables Keycloak to play the role of an MCP authorization server. In an environment where non-human identities are exploding, the assumption that "an admin manually registers every client" is collapsing — and CIMD is a signal of that shift.
In scenarios where agents act on behalf of users, delegation-chain design combined with Token Exchange (RFC 8693) also becomes important. Keycloak 26.6's JWT Authorization Grant support targets exactly these scenarios. In short, OAuth 2.1 has become the foundation layer that covers not just "humans logging in" but "agents acting under delegation".
Operational Best Practices
- Enforce via IdP policy. Do not rely on the goodwill of client developers; make PKCE S256 mandatory and disable implicit/ROPC as realm defaults. Keycloak 26.6 supports the FAPI 2.0 Security Profile at Final level, so in high-security environments applying the FAPI 2.0 client policy as-is is a solid choice.
- Keep token lifetimes short. Start with access tokens of 5-15 minutes and refresh tokens, with rotation, around idle 30 days / max 90 days, then tune.
- Use standard libraries only. Hand-rolling OAuth flows is an anti-pattern. Use proven libraries such as oidc-client-ts (SPA), AppAuth (mobile), Spring Security OAuth2 Client (Java), and golang.org/x/oauth2 (Go).
- Define monitoring metrics. The invalid_grant ratio, PKCE verification failures, and refresh token reuse detections are early signals of attack attempts.
- Migrate gradually. Apply OAuth 2.1 policies to new clients first, announce a deprecation schedule for legacy ones, then block them in stages.
Conclusion
OAuth 2.1 is not a spec that adds new features — it is a spec that turns 14 years of security lessons into defaults. To summarize:
- PKCE everywhere: whether public or confidential, always apply S256 PKCE to the authorization code flow.
- Forget Implicit and ROPC: SPAs use code+PKCE or a BFF, server-to-server uses client_credentials, CLIs use the device flow.
- Redirect URIs must match exactly: exact matching only, no wildcards.
- Refresh tokens need rotation + reuse detection: design the lifecycle assuming theft.
- The foundation layer of the MCP and AI agent era: designing new systems with OAuth 2.1 — plus FAPI 2.0 where needed — from day one is how you save future costs.
When the draft receives its RFC number does not matter. Its content is already mandated by RFC 9700, and every major IdP and library has finished implementing it. Start your migration now.
References
- OAuth 2.1 draft (draft-ietf-oauth-v2-1)
- RFC 6749 - The OAuth 2.0 Authorization Framework
- RFC 7636 - Proof Key for Code Exchange (PKCE)
- RFC 9700 - Best Current Practice for OAuth 2.0 Security
- RFC 8252 - OAuth 2.0 for Native Apps
- RFC 8628 - OAuth 2.0 Device Authorization Grant
- RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP)
- RFC 8693 - OAuth 2.0 Token Exchange
- OpenID Connect Core 1.0
- FAPI 2.0 Security Profile
- Keycloak Documentation
- Keycloak 26.6.0 Release Notes