Skip to content
< samuelsantana.dev />
Back to the Blog

The Silent OAuth Bug: Why the Token Should Never Travel Through the URL

Samuel Santana
Published on July 25, 2026(Edited on July 27, 2026)
OAuthSecurityNext.js

OAuth login works. The popup opens, the user approves on Google, the popup closes, and your app receives ?token=eyJhbGciOi... in the callback URL. You read the token off the query string, stash it somewhere, and the demo works perfectly on your screen.

That's exactly the moment most OAuth implementations introduce a silent security flaw β€” one that passes every manual test, every QA pass, and only shows up when someone audits production logs six months later.


1. Why the address bar isn't a safe place for a secret

A URL feels ephemeral β€” it appears, the user navigates away, it's gone. In practice, it gets recorded and replicated in several places you don't control:

  • Access logs. Web servers, reverse proxies, CDNs, and even the load balancer typically log the full request URL, query string included, by default β€” often for months, in plain text, in a logging system dozens of people have read access to.
  • Browser history. Stored locally, synced across devices if the user has browser sync on, and it outlives the login session by a lot.
  • The Referer header. If the page that receives the token loads any third-party resource (a font, an analytics script, a tracking pixel) before you clean up the URL, the browser may send the full URL β€” token included β€” as the Referer to that third party.
  • Client-side observability tooling. Sentry, LogRocket, Google Analytics and the like frequently capture the current URL as part of error or session context automatically. A misconfigured third-party SDK can literally exfiltrate the token outside your infrastructure without anyone writing a line of code for it β€” just wiring up the SDK with its defaults.

None of this is a sophisticated attack. It's the normal, documented behavior of browsers, proxies, and observability tools β€” which is exactly what makes this leak systemic instead of a one-off exploit.


2. The correct pattern: a code, not the token

The fix isn't new β€” it's literally why the Authorization Code Grant exists as a separate flow from the Implicit Grant in the OAuth 2.0 spec (and why the Implicit Grant was formally dropped in OAuth 2.1). The idea:

  1. The provider (Google, GitHub) redirects back to your app not with the real token, but with an authorization code β€” an opaque, single-use string with a short TTL (typically 30-60 seconds).
  2. The frontend receives that code in the URL (?code=xxx) β€” still a URL, but now carrying something worthless on its own and expiring almost instantly.
  3. The frontend makes a POST request (never GET, never in the URL) to your own backend, exchanging that code for the real token.
  4. The backend validates the code (does it exist? already used? expired?), exchanges it with the OAuth provider for the real tokens, and returns the result to the frontend as an httpOnly, secure, sameSite cookie β€” never as JSON the frontend's JavaScript can read.
// auth.controller.ts (NestJS) β€” provider callback endpoint
@Get('google/callback')
async googleCallback(@Query('code') providerCode: string, @Res() res: Response) {
  const { accessToken } = await this.authService.exchangeWithGoogle(providerCode);

  // Issue our OWN opaque, single-use, short-lived code β€”
  // never redirect with the real token in the URL.
  const exchangeCode = await this.authService.issueShortLivedExchangeCode(accessToken);

  return res.redirect(`${process.env.FRONTEND_URL}/auth/callback?code=${exchangeCode}`);
}

// separate endpoint, called via POST by the frontend
@Post('exchange')
async exchange(@Body('code') code: string, @Res() res: Response) {
  const { jwt } = await this.authService.redeemExchangeCode(code); // single-use, short TTL

  res.cookie('session', jwt, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
  });

  return res.json({ ok: true });
}

Notice what changes: even if that exchangeCode in the URL leaks into some log, it's worthless after being redeemed once (or after a few seconds). The real token never shows up in plaintext anywhere except server memory and an httpOnly cookie β€” which, by definition, the browser's JavaScript can't even read, which also mitigates token theft via XSS.


3. Two extra layers that are often missing

  • state parameter: a random value generated before redirecting to the provider, validated when it comes back. Without it, an attacker can start their own OAuth flow and trick a victim into completing their login β€” a classic CSRF against the auth flow.
  • PKCE (Proof Key for Code Exchange): originally designed for mobile apps/SPAs without a safe client secret, it's now recommended even for confidential clients. A code_verifier generated on the client, hashed into a code_challenge sent at the start of the flow, and the original code_verifier sent when redeeming the code β€” this guarantees only whoever started the flow can complete it, even if the authorization code is intercepted along the way.

Conclusion

Login "works" in both designs β€” token in the URL, or the exchange-code pattern. The difference never shows up on the happy path you test by hand; it shows up six months later, in an access log from a proxy nobody remembered existed, or in a security report asking why user session tokens show up in plaintext inside a third-party observability system.

Treat the address bar as a public, permanently recorded channel β€” because that's exactly what it is. Anything that's worth something on its own shouldn't travel through it, not even for a second.

Comments

Loading comments...

Join the conversation

Sign in with your account to comment on this article.