redirect_uri and the PKCE downgrade: two OAuth checks that fail open
OAuth redirect_uri validation and PKCE are two separate defences, and both fail the same way: they permit what they should refuse. Here is how loose redirect matching and a dropped code_challenge each hand an authorization code to an attacker, with the real CVEs, a lab, and the exact server-side checks that close them.
By Ahmed PingerPublished
9 min read5 views
An authorization code is a bearer secret with a short life and one job: prove that the browser which finished the OAuth dance is the one that started it. Two mechanisms are supposed to keep that code on rails. The redirect_uri decides where the code is allowed to land. PKCE binds the code to the client that requested it. This post is about the fact that both are routinely implemented so that they fail open: when the input is missing or slightly wrong, the server proceeds instead of refusing.
That single property, permitting instead of refusing, is the whole class. Get it into your head as the test and the rest of the post reads as variations on it.
The one distinction
redirect_uri and PKCE defend different steps and get confused constantly.
redirect_uriprotects the delivery of the code. After the user authenticates, the authorization server sends the code to a URL. If the server will send it to a URL the real client never registered, the code goes to the attacker. This is an authorization endpoint decision.- PKCE protects the redemption of the code. Even a code delivered correctly can be stolen in transit or through a second bug. PKCE makes a stolen code useless by requiring the redeemer to present a
code_verifiermatching thecode_challengefrom the start of the flow. This is a token endpoint decision.
Both are string comparisons the server can get lazy about, and both have the same failure mode when it does.
Why it varies: the server chooses how strict to be
The specification is not the ambiguous part. RFC 9700, the current OAuth 2.0 security best practice, is blunt on redirect matching:
When comparing client redirection URIs against pre-registered URIs, authorization servers MUST utilize exact string matching except for port numbers in
localhostredirection URIs of native apps.
Exact string matching. Not "same origin", not "starts with", not "matches this regex". The variance is entirely in what implementations do instead:
- Prefix matching.
https://client.example.com/callbackis registered; the server acceptshttps://client.example.com/callback.attacker.test/because it only checked that the string started with the registered value, and whatever the attacker appended rode along. Register a bare origin such ashttps://client.example.comand the same extension,https://client.example.com.attacker.test/, moves the host. - Subdomain or wildcard allowance. A registered
*.example.combecomes an open door the moment one subdomain is takeable (see the companion post on dangling DNS). - Open-redirector chaining. The registered URL is exact, but it forwards to a
next=parameter the attacker controls, so the code lands on the registered host and is immediately bounced onward.
PKCE varies in a narrower but nastier way: whether the token endpoint enforces the challenge it was given, or only checks the verifier when the client bothers to send one.
The real CVEs
Two published bugs make the pair concrete.
Redirect delivery: CVE-2023-28131. The Expo AuthSession redirect proxy (auth.expo.io) let applications centralise their social-login callback. NVD records it as a critical account-takeover flaw (CVSS 9.6, CWE-522). Salt Labs, who reported it, describe the mechanism plainly: the proxy trusted a returnUrl value and turned it into the destination for the OAuth response, so an attacker who got a victim to open a crafted link could have the victim's tokens delivered to an attacker domain. The fix was to stop honouring that destination without the user's approval, and to deprecate the proxy service outright. The lesson is not "Expo was careless"; it is that a redirect target taken from request input, rather than from what the client registered, is the bug regardless of who ships it.
Code redemption: CVE-2024-23647. authentik implemented PKCE, then implemented it so it could be switched off from the outside. NVD (CVSS 8.8, CWE-287) and the vendor advisory GHSA-mrx3-gxjx-hjqj describe a downgrade: an attacker who removes the code_challenge parameter from the authorization request causes authentik to skip the PKCE check entirely. This was the second attempt at the fix. The first, CVE-2023-48228, closed the case where the code_verifier was omitted at the token step but still failed when the challenge was dropped at the start. The pattern across both is the fail-open one: a missing parameter took the code path that permits, not the one that refuses.
RFC 9700 names this exact defence:
Beyond this, to prevent PKCE downgrade attacks, the authorization server MUST ensure that if there was no
code_challengein the authorization request, a request to the token endpoint containing acode_verifieris rejected.
Read it together with the base PKCE spec, RFC 7636, which says the server "verifies it by calculating the code challenge from the received code_verifier and comparing it with the previously associated code_challenge." The instinct is to think enforcement means "check the verifier". It also means "refuse the request whose shape does not match what the flow began with". A downgrade is the absence of a check masquerading as a pass.
A lab you can build
You need a Linux VM and Python. No Docker, no cloud tenant. A virtual environment keeps the packages off your system.
python3 -m venv oauthlab && . oauthlab/bin/activate
pip install flask
Write a deliberately loose authorization server: two endpoints, an in-memory store of issued codes, and the two weak comparisons on purpose so you can watch them break and then fix them.
# authlab.py: a teaching harness, not a production server
import secrets, hashlib, base64
from flask import Flask, request, jsonify
app = Flask(__name__)
REGISTERED = "https://client.example/callback"
codes = {} # code -> {"challenge": str|None}
@app.route("/authorize")
def authorize():
redirect_uri = request.args.get("redirect_uri", "")
# WEAK: prefix match instead of exact string match
if not redirect_uri.startswith(REGISTERED):
return "bad redirect_uri", 400
code = secrets.token_urlsafe(16)
codes[code] = {"challenge": request.args.get("code_challenge")}
return jsonify(code=code, sent_to=redirect_uri)
@app.route("/token", methods=["POST"])
def token():
code = request.form.get("code")
entry = codes.pop(code, None)
if not entry:
return "unknown code", 400
verifier = request.form.get("code_verifier")
# WEAK: only checks the verifier when one is supplied
if entry["challenge"] and verifier:
calc = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
if calc != entry["challenge"]:
return "pkce mismatch", 400
return jsonify(access_token="demo-token-for-" + code)
Run it with flask --app authlab run and drive it with curl. Two probes reproduce the two failure classes against your own harness:
# 1. Loose redirect: the registered string, extended with attacker-chosen characters
curl "http://127.0.0.1:5000/authorize?redirect_uri=https://client.example/callback.attacker.test/"
# 2. PKCE downgrade: start a flow WITH a challenge, redeem WITHOUT a verifier
CODE=$(curl -s "http://127.0.0.1:5000/authorize?redirect_uri=https://client.example/callback&code_challenge=abc" | python3 -c "import sys,json;print(json.load(sys.stdin)['code'])")
curl -s -X POST http://127.0.0.1:5000/token -d "code=$CODE"
The first returns a code, with sent_to echoing a redirect the client never registered: startswith accepted everything appended after the registered string. The second returns a token although the flow demanded PKCE. Both are the fail-open branch. Now change startswith to ==, and change the token check so a stored challenge with no verifier is a hard rejection, and rerun. Both probes turn into 400s. You have reproduced the class and its fix in about thirty lines.
Finding it in source
You are hunting for the permissive branch, not for the word "OAuth".
- Redirect matching that is not equality. Grep for
startswith,startsWith,indexOf(... ) === 0,RegExpandLIKEnear aredirect_uriorredirectUrivariable. Any comparison that is not a full-string equal against a stored allowlist is suspect. - Redirect values sourced from the request. Trace where the callback URL comes from. If it is read from a query parameter, a
Referer, or areturnUrl/nextfield rather than looked up from the registered client record, that is the CVE-2023-28131 shape. - PKCE checks guarded by "if present". Find the token endpoint and read the condition around the verifier comparison.
if (code_verifier)orif verifier is not Nonegating the check is the downgrade. The correct logic is: if the stored authorization had a challenge, a redemption without a valid verifier fails, full stop. - Wildcards in client registration. Search the client config for
*in registered redirect URIs. Each one is a standing dependency on every host it can match.
Fixing it
- Exact-match redirect URIs against the registered set. Compare the full string, scheme and host and path and all, to the values the client registered. Reject on any difference. Do not normalise the attacker's input into a match.
- Register full URLs, not patterns. Drop wildcard and subdomain registrations. If a client genuinely needs several callbacks, register each one explicitly.
- Enforce PKCE from the challenge, not the verifier. The token endpoint's decision is driven by what the authorization request contained. Stored a challenge and got no verifier: reject. Stored no challenge and got a verifier: reject. Public clients should be required to use PKCE at registration so "no challenge" is itself refused.
- Do not chain to request-controlled redirects. A registered callback that forwards to a
next=parameter re-opens everything exact matching just closed. Validate the onward target against the same allowlist or drop it.
Detection
The authorization server's own logs answer most of this without a scanner.
- Alert on token redemptions where the stored authorization carried a
code_challengebut the request carried nocode_verifier. On a patched server that is a rejected request; a burst of them is someone probing for the downgrade. - Watch for
authorizerequests whoseredirect_uriresolves to a host outside your registered set, especially values that share a registered prefix and then diverge. - Review client registrations for wildcard redirect URIs as a recurring hygiene check, the same way you review over-broad firewall rules.
Take this away
redirect_uri validation and PKCE are two independent guards on the same authorization code, and both fail the same way: by permitting the request whose shape does not match what the flow began with. Exact-string redirect matching against registered clients closes the delivery side. Enforcing PKCE from the presence of the challenge, not the presence of the verifier, closes the redemption side. When you review either one, do not ask "does the check pass on good input". Ask "what does the server do when the input is missing or almost right", because that branch is the vulnerability.
Sources
- NVD: CVE-2023-28131: Expo AuthSession redirect proxy, account takeover, CVSS 9.6
- Salt Labs: New OAuth vulnerability that may impact hundreds of online services: the
returnUrlmechanism and Expo's fix - NVD: CVE-2024-23647: authentik PKCE downgrade via dropped
code_challenge, CVSS 8.8 - authentik advisory GHSA-mrx3-gxjx-hjqj: the downgrade description and impact
- NVD: CVE-2023-48228: the earlier authentik PKCE fix that missed the challenge-drop case
- RFC 9700: Best Current Practice for OAuth 2.0 Security: exact redirect matching and PKCE downgrade rejection
- RFC 7636: Proof Key for Code Exchange: what the token endpoint does with
code_verifier - PortSwigger Web Security Academy: OAuth 2.0 authentication vulnerabilities: redirect_uri bypass techniques
Was this useful?
Get the next write-up by email
New technical write-ups by email when we publish. No sales sequence, and every email has an unsubscribe link.
Prefer a feed? RSS. How we handle your address: privacy notice.
Comments
Loading comments…