Google OAuth has one rule that doesn't care how your app is architected: it redirects to exactly one registered callback URL. In my templates that URL points at the Rails or Go API, not the Next.js frontend, because the API is what holds the Google client secret and does the token exchange. So the API finishes the OAuth dance holding a logged-in user, and now has to get that session across to a frontend on a different origin, one it doesn't share a cookie domain with.
The tempting move is to put the session token in the redirect. One query parameter, the frontend reads it, done. It's also the mistake this post exists to talk you out of.
The wrong answer, in one query parameter
Say the callback controller mints the session JWT and redirects to https://app.example.com/auth/callback?token=<jwt>. It works. It also means that token, a credential that's valid for whatever the frontend's session cookie lifetime is (seven days, in both templates), now exists in:
- Browser history. Every browser that isn't in a private window keeps this in the URL bar's autocomplete indefinitely.
- Every proxy and server access log between Google and the user's browser. Load balancers, reverse proxies, CDN edge logs: anything that logs request URLs now has a copy.
- The referer header. If the callback page loads so much as an external image, a font from a CDN, an analytics pixel, the full URL including the token goes out in the
Refererheader to that third party.
None of that is a coding mistake. It's what URLs do: built to be logged, copied, and passed along, which is exactly wrong for something that proves who you are for the next week. The Rails controller in my template spells this out in its own header comment:
"Never put the session JWT in the redirect URL. It would land in browser history, in every proxy's access log, and in a referer header, and it is a 7-day bearer credential."
The email login flow never has this problem, because it's a same-origin fetch: the frontend POSTs credentials, gets a token back in a response body, and sets its own cookie. OAuth is the one path forced through a browser redirect, so it's the one path that has to work around the redirect instead of trusting it.
The handoff code instead
Both templates solve it the same way: the callback controller never mints the session token at all. It mints something much smaller, a single-use, short-lived, opaque code, and redirects with that instead.
def google
user = User.from_google(request.env["omniauth.auth"])
code = user.issue_oauth_handoff_code!
redirect_to "#{frontend_url}/auth/callback?code=#{CGI.escape(code)}", allow_other_host: true
endGo does the same thing with its own row in an oauth_exchange_codes table, generated with the same randomness it uses for the CSRF state value:
exchangeCode, err := generateState()
// ...
CreateOAuthExchangeCode(r.Context(), db.CreateOAuthExchangeCodeParams{
Code: exchangeCode, UserID: user.ID,
ExpiresAt: time.Now().Add(oauthExchangeCodeTTL),
})oauthExchangeCodeTTL is 60 seconds. Rails' equivalent, OAUTH_HANDOFF_VALIDITY, is 1.minute. Neither is padding, it's sized to the one thing the code has to survive: the frontend's own server immediately calling back to redeem it.
The code by itself is useless. It doesn't identify the user to anyone who can't also reach the API's exchange endpoint, and it can only be redeemed once. Even sitting in the same places a token would sit, browser history included, a stale one-time code from a session that's already over is worth nothing to whoever finds it.
Why the claim has to be atomic
A one-time code is only actually one-time if two people racing to redeem it can't both win. That means the check-and-clear has to be a single operation, not a read followed by a separate write.
Rails does it with a scoped update_all, so the digest match and the clear happen in the same statement and the database's own row-level locking decides the winner:
claimed = where(id: user.id, oauth_handoff_digest: digest)
.update_all(oauth_handoff_digest: nil, oauth_handoff_issued_at: nil, updated_at: Time.current)
return nil if claimed.zero?Whichever request's UPDATE actually matches a row wins; the other matches zero rows and gets nil back. Expiry is checked after that claim, not before, and the same nil covers "not found," "already used," and "expired" on purpose: "telling them apart is exactly what an attacker probing codes wants," per the same controller's comment on #exchange.
Where the cookie actually gets set
The code buys one more redirect, from the API back to the frontend's own /auth/callback route: server-side Next.js code running on the frontend's origin, and the only thing in this flow allowed to set the cookie the frontend will actually use.
const res = await serverFetch('/auth/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
})
if (res.ok) {
const data = (await res.json().catch(() => ({}))) as { token?: string }
token = data.token
}This call never goes through the browser. It's the frontend's server calling the backend's /auth/exchange endpoint directly, trading the code for the actual JWT in a response body, which lands immediately in an httpOnly cookie the route sets on its own response:
response.cookies.set('session', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: SEVEN_DAYS,
})At no point does the token cross a URL. The redirect from Google carries the throwaway code; the token only ever travels inside a request body, over a connection the browser never sees. This is the same lesson as password auth in these templates: the frontend, not the API, is what's allowed to set a cookie on the frontend's domain, so every login path, password or Google, ends at the same Next.js route setting the same cookie, just arriving by different means.
The same shape in Go
I keep pointing at Rails first because its comments spell the reasoning out, but the Go template makes the identical call, independently: mint a code, not a token, redeem it server-to-server. Its own OAuth callback comment says it plainly:
// A session JWT in the redirect URL would leak into logs, history and
// referers, so hand over a one-time opaque code instead: the frontend
// redeems it server-to-server at /auth/exchange.And the Go frontend route redeems it the same way, with less scaffolding around it:
const res = await fetch(`${API_URL}/auth/exchange`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
})Same shape, arrived at independently in two languages sharing nothing but the constraint. That's usually a sign a design is forced by the problem rather than house style. I've written before about where Rails and Go genuinely diverge; this isn't one of those places. Once you accept the token can't touch a URL, there's really only one shape left.
The traps that actually cost time
Two things here weren't obvious going in, and neither is really about the handoff code itself.
OmniAuth wants a session inside an otherwise sessionless API. Every other endpoint in the Rails backend is a plain JSON API, stateless, authenticated by a bearer cookie on each request. OmniAuth doesn't work that way: its CSRF protection for the OAuth state parameter needs somewhere to stash the value it minted before redirecting to Google, and that somewhere is the session. The controller's file header explains why it inherits from ApplicationController rather than the API base class the rest of the app uses:
"OmniAuth needs the session (its
stateCSRF param lives there) and every action here is a full-page browser redirect."
SameSite=Lax is what lets that session cookie survive the round trip through Google and back. It's a small, deliberate exception carved out of an otherwise cookie-free API. The Go backend sidesteps it entirely: it stores state in its own short-lived, signed oauth_state cookie rather than a server-side session, because there's no server-side session to put it in. Same CSRF problem, no session to solve it with, so no session gets introduced just to solve it.
Google not being configured at all is a real path, not an edge case. Both templates ship to run out of the box with no Google Cloud project set up, so "Google isn't configured" has to be a handled state, not a crash. Rails checks GOOGLE_CLIENT_ID at boot and only mounts the OmniAuth middleware if it's present; without it, GET /auth/google falls through to routes and hits an action that redirects straight to /login?error=google_unavailable. Go checks the same thing per-request, at the top of the handler, and redirects the same way. Both use that identical query parameter, so on the frontend, "not configured," "consent denied," and "token exchange failed" all collapse into the same message.
None of this is exotic. It's the ordinary cost of a callback that has to cross an origin boundary the browser won't let you paper over.
Both flows, Google and password, ship wired up in the Next.js SaaS templates with a Rails or Go backend, so it's one more piece of infrastructure you don't have to re-derive from a Stack Overflow answer that's already forgotten about the referer header.