fix(web): persist Google refresh token in JWT for automatic token refresh#2208
fix(web): persist Google refresh token in JWT for automatic token refresh#2208
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
Codecov Report❌ Patch coverage is
📢 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>
3259caa to
6c3b328
Compare
There was a problem hiding this comment.
- Persisting
refresh_tokeninto 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 whentoken.refreshTokenis absent (existing sessions, non-Google providers). - The JWT type currently implies requiredprovider/ideven 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 marksproviderandidas required. In thejwtcallback, these are only set whenaccount/userare present. On subsequent invocations (noaccount, sometimes nouser), 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_tokenin the NextAuth JWT during initial sign-in:- In
apps/web/lib/auth.ts, thejwtcallback now storesaccount.refresh_tokenintotoken.refreshTokenalongsideaccessToken,idToken, andprovider.
- In
- Refactored token refresh flow to rely on the JWT-stored refresh token:
refreshTokenIfNecessary()now accepts onlytoken: JWTand readstoken.refreshTokeninstead ofaccount?.refresh_token.- Updated call site to
refreshTokenIfNecessary(token).
- Extended NextAuth JWT typing:
- Added optional
refreshToken?: string;toapps/web/types/next-auth.d.tsunderdeclare module "next-auth/jwt".
- Added optional
Why it matters
- Fixes the core issue where
accountis only present on initial sign-in, enabling automatic OIDC token refresh after page reloads (e.g., after Google ID token expiry ~1 hour).
| 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); |
There was a problem hiding this comment.
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).
| 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; | ||
| } |
There was a problem hiding this comment.
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.
Summary
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:accountobject (containingrefresh_token) is only populated during initial sign-inaccountwasnull, so the refresh code returned earlySolution
account.refresh_tokenintoken.refreshTokenduring initial sign-inrefreshTokenIfNecessary()to read fromtoken.refreshTokeninstead ofaccount?.refresh_tokenrefreshTokento the JWT TypeScript interfaceTest plan
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