·
OAuth 2.1: What Actually Changed, and What "Supporting" It Really Means
OAuth 2.1 is still a draft, not an RFC, and it does not add anything new. It deletes the parts of OAuth 2.0 that kept causing the same five vulnerabilities. What each removal closes, and what "OAuth 2.1 compliant" should mean in practice.
OAuth 2.1 is not a new protocol. It has no new grant type, no new endpoint, no new token format. What it does is take OAuth 2.0 plus fifteen years of security best-current-practice RFCs (PKCE, the Security BCP, the Browser-Based Apps BCP) and fold them into one document, then delete the parts of 2.0 that those RFCs existed to work around. It’s currently draft-ietf-oauth-v2-1-15 (March 2026), still an IETF Internet-Draft rather than a ratified RFC, and it’s been in that state since 2020. Treat “we support OAuth 2.1” claims accordingly: there’s no version number to point at, only a checklist of what got removed.
The removals matter more than the framing. Each one existed because a specific vulnerability kept showing up in real deployments long after OAuth 2.0 shipped, patched piecemeal by extension RFCs that most implementers never read. OAuth 2.1 just makes the patches mandatory by deleting the vulnerable path outright.
What actually changed
| OAuth 2.0 | OAuth 2.1 | |
|---|---|---|
| PKCE | Optional, mobile-only in practice | Required for every client, confidential or public |
code_challenge_method=plain | Allowed | Gone; S256 only |
Implicit flow (response_type=token) | Defined | Removed |
| Resource Owner Password Credentials | Defined | Removed |
| Refresh token rotation | Unspecified | Required for public clients |
| Bearer tokens in the URL query string | Allowed | Disallowed |
PKCE becomes mandatory, and plain disappears
PKCE was written for one specific attacker: a malicious app on the same device as a legitimate public client, capable of registering the same custom URI scheme and intercepting the redirect. Without it, the attacker who captures the authorization code can exchange it for a token directly. The fix binds the code to a secret the legitimate app generated before the redirect ever happened.
code_verifier = random_string(43-128 chars) # kept by the client
code_challenge = BASE64URL(SHA256(code_verifier)) # sent in /authorize
GET /authorize?...&code_challenge=E9Melhoa2...&code_challenge_method=S256
POST /token
grant_type=authorization_code&code=...&code_verifier=dBjftJeZ...
An attacker who intercepts the code still doesn’t have code_verifier, so the exchange fails.
OAuth 2.0 scoped this to public clients on the (reasonable at the time) assumption that a confidential client’s client secret already prevented code exchange by anyone else. What that assumption misses: a leaked authorization code doesn’t require the attacker to authenticate as the client at all if they can reach the token endpoint before the legitimate app does, and confidential clients redirect through browsers just as often as public ones now (server-side web apps, BFF patterns). OAuth 2.1 requires PKCE for every client type, no exceptions.
The code_challenge_method=plain option OAuth 2.0’s PKCE RFC allowed (where code_challenge is just the verifier, unhashed) is gone too. plain existed for clients that couldn’t compute SHA-256, which stopped being a real constraint once every mobile platform got a native crypto API. Keeping it around meant a PKCE implementation could be spec-compliant and still not actually resist code interception, because an attacker who captures the challenge captures the verifier too.
Implicit flow: removed, not just discouraged
Implicit flow (response_type=token) returned the access token directly in the redirect URI’s fragment; there was no code exchange step. Three consequences fall out of putting a bearer token in a URL fragment:
- Browsers log the full URL, including the fragment, in history, so the token sits there indefinitely.
- Redirect chains, and some browser extensions, leak the current URL forward. A fragment is supposed to stay client-side only, but that guarantee depends on every piece of code in the page’s lifecycle honoring it, which in practice nothing verifies.
- Fragment-delivered tokens can’t be rotated, because there’s no token endpoint request to attach a refresh token to.
Authorization Code + PKCE replaced it for every client type implicit used to justify: SPAs get a code redirect and exchange it from JavaScript with PKCE protecting the exchange; native apps do the same through a system browser. There’s no client type implicit flow could serve that this combination can’t.
ROPC: removed for the reason OAuth existed
Resource Owner Password Credentials took the user’s actual login and password as form fields on the client, and exchanged them directly for a token. This isn’t a hardening gap the way PKCE or implicit are. It’s a direct contradiction of the reason OAuth exists as a protocol distinct from “the app asks for your password.” The user’s password becomes visible to any client the flow is enabled for, permanently, with no scoping and no way to distinguish “the client is renewing my session” from “the client is doing something to my account with my full credentials.”
Device Authorization Grant replaced ROPC’s actual use case (input-constrained devices with no browser: TVs, CLI tools). It shows the user a code and a URL to visit on a separate device, and the device polls the token endpoint until that’s done. The device that needs the token never sees the password at all.
Refresh token rotation: closes a silent-replay window
Static, long-lived refresh tokens create the same offline-attack surface as long-lived passwords: one that leaks (from a compromised device, a logging pipeline, an insecure local storage read) is valid until the client bothers to revoke it, which for a public client with no server-side session store might be never. Rotation forces a decision on every use: each refresh exchange invalidates the token that was just spent and issues a new one, so a stolen token is only usable until the legitimate client’s next refresh: typically minutes to hours, not the token’s full multi-week lifetime.
The interesting edge case is what happens when the stolen token gets used first. The legitimate client’s next refresh attempt then fails, because its refresh token was already rotated out from under it by the attacker. That failure is the detection signal: a refresh token being rejected as already-used means two parties had a copy of the same token, which should never happen if it hasn’t leaked. What an implementation does with that signal (silently re-issue, or treat it as evidence of compromise and kill the whole session) is the part the spec leaves as a judgment call. See how Versola implements it below for where that line actually sits in practice.
Bearer tokens move out of the URL
Passing access_token as a query parameter puts the token everywhere URLs go: web server access logs, browser history, Referer headers sent to third-party resources on the same page, proxy logs along the request path. None of those systems are designed to treat a query parameter as a secret. OAuth 2.1 requires the Authorization: Bearer <token> header, which none of those layers log by default.
Where “supporting OAuth 2.1” quietly fails
Claiming compliance and actually closing these gaps are different things. The places implementations most often get it wrong:
- PKCE required in the spec, optional in the code. Confidential clients get an exemption “because they have a client secret,” reintroducing the exact gap 2.1 closed. If
code_verifieris anything but a required field in the token request, PKCE isn’t actually mandatory. code_challenge_methodaccepts more thanS256. Supportingplainfor backward compatibility with an old client library defeats the entire mechanism for any request that uses it. There’s no partial credit for PKCE.- Implicit and ROPC are disabled by config, not absent by design. A feature flag can be flipped back on, by a future engineer who doesn’t know why it was off, or by a bug. Removing them from the routing table entirely, so
response_type=tokenandgrant_type=passwordfail closed with “unsupported” instead of failing a check that could theoretically pass, is the difference between “off” and “gone.” - Refresh rotation without atomicity. If issuing the new refresh token and invalidating the old one aren’t the same transaction, a client that retries a timed-out refresh request can end up with two valid tokens from one logical exchange, or the reuse-detection signal never fires because both requests raced to read the same not-yet-invalidated token.
- Bearer-in-query still works “for compatibility.” Accepting a bearer token from
?access_token=...in addition to the header preserves the vulnerability for anyone still using the old integration path, which is usually the whole reason the exception was added in the first place.
A checklist for evaluating an OAuth implementation
- Is PKCE validated on every authorization code exchange, or only for clients marked “public”?
- Does
code_challenge_methodaccept anything besidesS256? - Do
response_type=tokenandgrant_type=passwordfail as unsupported, structurally, or are they disabled config that something could re-enable? - Is refresh token rotation atomic (old token invalidated and new one issued in the same transaction), and does token reuse produce a distinguishable signal rather than the same error as an expired token?
- Are bearer tokens accepted anywhere other than the
Authorizationheader?
How Versola implements it
code_challenge and code_challenge_method are required parameters on every /authorize request Versola’s auth service parses. There’s no client-type branch that skips them, and a request missing either fails with invalid_request before an authorization code is ever issued. code_challenge_method accepts exactly one value, S256; anything else, including plain, fails with CodeChallengeMethodInvalid. code_verifier is a required field on the token endpoint’s authorization-code grant decoder, so the exchange itself enforces PKCE independently of whether /authorize did.
response_type accepts code or code id_token and nothing else. token isn’t a case the parser recognizes, so implicit flow isn’t a disabled feature; it’s a value the request grammar has no path for. The token endpoint’s grant-type dispatch recognizes exactly authorization_code, refresh_token, and client_credentials; password falls through to unsupported_grant_type for the same structural reason.
Refresh token rotation deletes the previous token and inserts its replacement in one database transaction, and the replacement’s row references the token it rotated from through a column with a UNIQUE constraint. If the same refresh token is redeemed twice (the reuse case rotation exists to catch), the second transaction’s insert collides with the first’s on that constraint and fails atomically; there’s no window where a race produces two valid children of one token. What that failure currently returns to the client is a plain invalid_grant, identical to an expired or unknown token. It stops the replay, but it doesn’t yet distinguish “this token was reused” from “this token doesn’t exist” for a caller trying to detect compromise from the API response alone. That’s a real gap between what the rotation mechanism can detect internally and what it currently surfaces, and it’s on our list to close by making reuse trigger session revocation rather than a silent reject.
Every endpoint that accepts a bearer token (/userinfo, token introspection) reads it exclusively from the Authorization header; there’s no code path that also checks a query parameter, so there’s nothing to disable.