Skip to content
< samuelsantana.dev />
Back to the BlogCover: a callback URL carrying ?token= branching into access logs, history, the Referer header and an analytics SDK; beside it, the safe flow: ?code=, POST /auth/exchange, httpOnly cookie.

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 many 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 resource before you clean up the URL, requests to your own origin carry the full URL β€” token included β€” in the Referer, and it ends up in your server's and your CDN's logs. For third parties (a font, an analytics script, a tracking pixel), the modern browser default (strict-origin-when-cross-origin) sends only the origin; the full URL only goes along if the page loosens its Referrer-Policy (unsafe-url, no-referrer-when-downgrade).
  • 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 is discouraged by RFC 9700, the OAuth 2.0 Security BCP, and was removed from the still-draft 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 (RFC 6749 recommends 10 minutes at most; GitHub uses exactly 10).
  2. The frontend receives that code in the URL (?code=xxx) β€” still a URL, but now carrying something worthless on its own and expiring within minutes.
  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 exchanges the code with the OAuth provider for the real tokens (it's the provider that rejects a code that doesn't exist, was already used, or has expired), and returns the result to the frontend as an httpOnly, secure, sameSite cookie β€” never as JSON the frontend's JavaScript can read.

In the popup flow of the snippet below, the provider redirects straight to the backend, which does that exchange on the spot; the ?code= that reaches the frontend is a second code, issued by the backend itself, with the same properties β€” opaque, single-use, and short-lived (60 seconds, say).

// 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. One detail the snippet doesn't show: that res.cookie stores the cookie on the API's host. That's enough when the API itself is what reads it, on the same site as the frontend; if the API and the frontend live on different domains, or the frontend's server is what needs the cookie (as in a Next.js app), the API returns the token in a server-to-server call and the frontend's server sets the cookie, on its own domain.


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...