Skip to content

fix(web): persist Google refresh token in JWT for automatic token refresh#2208

Open
alecf wants to merge 1 commit intomainfrom
alecf/refresh-token
Open

fix(web): persist Google refresh token in JWT for automatic token refresh#2208
alecf wants to merge 1 commit intomainfrom
alecf/refresh-token

Conversation

@alecf
Copy link
Contributor

@alecf alecf commented Feb 5, 2026

Summary

  • Fix automatic token refresh for Google OAuth by persisting the refresh token in the JWT
  • Previously, refresh tokens were only available during initial sign-in and couldn't be accessed on subsequent page loads
  • Now tokens are stored in the JWT, enabling automatic refresh when users return after the ID token has expired (~1 hour)

Problem

Google ID tokens expire after 1 hour. The refresh logic in refreshTokenIfNecessary() existed but couldn't access the refresh token on subsequent page loads because:

  1. The account object (containing refresh_token) is only populated during initial sign-in
  2. The refresh token wasn't being persisted in the JWT
  3. On subsequent visits, account was null, so the refresh code returned early

Solution

  1. Store account.refresh_token in token.refreshToken during initial sign-in
  2. Update refreshTokenIfNecessary() to read from token.refreshToken instead of account?.refresh_token
  3. Add refreshToken to the JWT TypeScript interface

Test plan

  • Log out and log back in with Google
  • Wait for ID token to expire (~1 hour) or manually test with shorter expiry
  • Refresh the page and verify the smoketest still works (no 400 errors on token exchange)

Note: Existing logged-in users will need to log out and back in to get a JWT with the refresh token stored.

🤖 Generated with Claude Code

@charliecreates charliecreates bot requested a review from CharlieHelps February 5, 2026 02:15
@vercel
Copy link

vercel bot commented Feb 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cloud Ready Ready Preview, Comment Feb 5, 2026 3:02am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
showcase Skipped Skipped Feb 5, 2026 3:02am
tambo-docs Skipped Skipped Feb 5, 2026 3:02am
@github-actions github-actions bot added area: web Changes to the web app (apps/web) status: in progress Work is currently being done contributor: tambo-team Created by a Tambo team member change: fix Bug fix labels Feb 5, 2026
@codecov
Copy link

codecov bot commented Feb 5, 2026

Codecov Report

❌ Patch coverage is 50.00000% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
apps/web/lib/auth.ts 50.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

…resh

Google ID tokens expire after 1 hour. The refresh logic existed but couldn't
access the refresh token on subsequent page loads because it was only available
in the `account` object during initial sign-in.

Now the refresh token is stored in the JWT during initial sign-in, making it
available on every session access. This allows automatic token refresh when
users return after the ID token has expired.

Note: Existing logged-in users will need to log out and back in to get a JWT
with the refresh token stored.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@alecf alecf force-pushed the alecf/refresh-token branch from 3259caa to 6c3b328 Compare February 5, 2026 02:18
@vercel vercel bot temporarily deployed to Preview – tambo-docs February 5, 2026 02:18 Inactive
@vercel vercel bot temporarily deployed to Preview – showcase February 5, 2026 02:18 Inactive
Copy link
Contributor

@charliecreates charliecreates bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Persisting refresh_token into the JWT is a significant security decision and likely should be replaced with server-side storage or explicit hardening (encryption/limited exposure). - refreshTokenIfNecessary() should gracefully skip refresh when token.refreshToken is absent (existing sessions, non-Google providers). - The JWT type currently implies required provider/id even though callback execution order can make them temporarily absent, so adding optionality or guards would improve robustness.
Additional notes (1)
  • Maintainability | apps/web/types/next-auth.d.ts:16-22
    The JWT typing marks provider and id as required. In the jwt callback, these are only set when account/user are present. On subsequent invocations (no account, sometimes no user), you rely on prior token state being present.

That’s fine when the initial token was properly created, but it makes token-shape assumptions implicit and increases the risk of runtime edge cases (e.g., partial tokens during certain auth flows).

Summary of changes

What changed

  • Persisted Google refresh_token in the NextAuth JWT during initial sign-in:
    • In apps/web/lib/auth.ts, the jwt callback now stores account.refresh_token into token.refreshToken alongside accessToken, idToken, and provider.
  • Refactored token refresh flow to rely on the JWT-stored refresh token:
    • refreshTokenIfNecessary() now accepts only token: JWT and reads token.refreshToken instead of account?.refresh_token.
    • Updated call site to refreshTokenIfNecessary(token).
  • Extended NextAuth JWT typing:
    • Added optional refreshToken?: string; to apps/web/types/next-auth.d.ts under declare module "next-auth/jwt".

Why it matters

  • Fixes the core issue where account is only present on initial sign-in, enabling automatic OIDC token refresh after page reloads (e.g., after Google ID token expiry ~1 hour).
Comment on lines 229 to +239
async jwt({ token, account, user }) {
// console.log("AUTH ROUTE: jwt callback with", token, account, user);
// Persist the OAuth access_token to the token right after signin
// Persist the OAuth access_token and refresh_token to the token right after signin
if (account) {
token.accessToken = account.access_token;
token.provider = account.provider;
token.idToken = account.id_token;
token.refreshToken = account.refresh_token;
}

const refreshedToken = await refreshTokenIfNecessary(account, token);
const refreshedToken = await refreshTokenIfNecessary(token);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storing the OAuth refresh_token inside the NextAuth JWT is a high-sensitivity change: depending on your NextAuth session/JWT configuration, this may end up readable by the browser (e.g., when sessions are exposed client-side) and/or increase the blast radius if the JWT leaks. Even if it’s only in an HttpOnly cookie, it’s still a long-lived credential.

At minimum, this warrants an explicit security posture decision (server-only usage, encryption, rotation/revocation strategy) and ideally a safer persistence mechanism (e.g., store refresh token server-side and keep only an opaque reference in the JWT).

Suggestion

Consider storing the provider refresh token server-side (DB/kv) keyed by user+provider, and keep only a short opaque refreshTokenId in the JWT. Then refreshTokenIfNecessary() can look up the refresh token by token.id/token.provider.

If you must keep it in the JWT, ensure it is not exposed via session callback and that your NextAuth JWT cookie is HttpOnly + secure; also consider encrypting the token (NextAuth supports JWT encryption depending on configuration) and explicitly document the risk.

Reply with "@CharlieHelps yes please" if you’d like me to add a commit that switches to a server-side stored refresh token reference (or adds guardrails/documentation if you keep JWT storage).

Comment on lines 229 to 237
async jwt({ token, account, user }) {
// console.log("AUTH ROUTE: jwt callback with", token, account, user);
// Persist the OAuth access_token to the token right after signin
// Persist the OAuth access_token and refresh_token to the token right after signin
if (account) {
token.accessToken = account.access_token;
token.provider = account.provider;
token.idToken = account.id_token;
token.refreshToken = account.refresh_token;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

account can be present for any OAuth provider (including ones that may not return a refresh_token or only return it on first consent). Right now you unconditionally overwrite token.refreshToken on every sign-in when account exists, which can accidentally clobber a previously stored refresh token with undefined.

This is a correctness issue because it can silently break refresh on subsequent requests even though the user previously had a valid refresh token stored.

Suggestion

Only set token.refreshToken when the provider actually supplies a value, and consider guarding by provider if this refresh logic is Google-specific.

if (account) {
  token.accessToken = account.access_token;
  token.provider = account.provider;
  token.idToken = account.id_token;

  if (account.refresh_token) {
    token.refreshToken = account.refresh_token;
  }
}

If you want to ensure it never gets cleared accidentally, avoid assigning undefined and only update on truthy values.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: web Changes to the web app (apps/web) change: fix Bug fix contributor: tambo-team Created by a Tambo team member status: in progress Work is currently being done

1 participant