Token Refresh
When using sessionFields to store OAuth access tokens in the session cookie, those tokens will eventually expire. This guide covers how to proactively refresh them before they expire, keeping the user’s session alive without re-authentication.
Store the access token, refresh token, and expiry timestamp in the session cookie:
import { createAuth } from 'ideal-auth';
type User = { id: string; email: string; name: string; accessToken: string; refreshToken: string; expiresAt: number; // Unix seconds when the access token expires};
export const auth = createAuth<User>({ secret: process.env.IDEAL_AUTH_SECRET!, cookie: createCookieBridge(), sessionFields: ['email', 'name', 'accessToken', 'refreshToken', 'expiresAt'],});When logging in (after OAuth callback or cross-domain transfer), include all three values:
await auth().login({ id: profile.sub, email: profile.email, name: profile.name, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: Math.floor(Date.now() / 1000) + tokens.expires_in,});Refresh helper
Section titled “Refresh helper”Create a helper that exchanges a refresh token for new tokens. This is provider-specific — the example below shows a standard OAuth2 token endpoint:
export async function refreshAccessToken(refreshToken: string) { const response = await fetch(process.env.OAUTH_TOKEN_URL!, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: process.env.OAUTH_CLIENT_ID!, client_secret: process.env.OAUTH_CLIENT_SECRET!, }), });
if (!response.ok) { return null; }
const data = await response.json();
return { accessToken: data.access_token as string, refreshToken: (data.refresh_token as string) ?? refreshToken, // some providers rotate refresh tokens expiresIn: data.expires_in as number, };}Proactive refresh
Section titled “Proactive refresh”Check the token expiry before making API calls. If the token is expired or about to expire, refresh it and update the session cookie. The key is that auth().login() replaces the session cookie — calling it again with updated tokens is the refresh mechanism.
import { auth } from './auth';import { refreshAccessToken } from './refresh-token';
const REFRESH_BUFFER_SECONDS = 60; // refresh 60 seconds before expiry
/** * Ensure the access token is fresh. If expired or about to expire, * refresh it and update the session cookie. * * Returns the current user with a valid access token, or null * if the session is invalid or the refresh failed. */export async function ensureFreshToken() { const session = auth(); const user = await session.user();
if (!user) return null;
const now = Math.floor(Date.now() / 1000);
// Token is still fresh — return as-is if (user.expiresAt > now + REFRESH_BUFFER_SECONDS) { return user; }
// Token expired or about to expire — refresh it const tokens = await refreshAccessToken(user.refreshToken);
if (!tokens) { // Refresh failed — session is no longer valid await session.logout(); return null; }
// Update the session cookie with new tokens const updatedUser = { ...user, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: now + tokens.expiresIn, };
await session.login(updatedUser);
return updatedUser;}Call ensureFreshToken() before any API call that requires the access token:
'use server';
import { ensureFreshToken } from '@/lib/ensure-fresh-token';import { redirect } from 'next/navigation';
export async function fetchDashboardData() { const user = await ensureFreshToken();
if (!user) { redirect('/login'); }
const res = await fetch('https://api.example.com/dashboard', { headers: { Authorization: `Bearer ${user.accessToken}` }, });
return res.json();}import type { Request, Response, NextFunction } from 'express';import { ensureFreshToken } from '../lib/ensure-fresh-token';
export async function requireFreshToken( req: Request, res: Response, next: NextFunction,) { const user = await ensureFreshToken();
if (!user) { return res.status(401).json({ error: 'Session expired.' }); }
req.user = user; next();}import { redirect, type Handle } from '@sveltejs/kit';import { ensureFreshToken } from '$lib/server/ensure-fresh-token';
export const handle: Handle = async ({ event, resolve }) => { const user = await ensureFreshToken();
event.locals.user = user;
if (!user && event.url.pathname.startsWith('/dashboard')) { redirect(303, '/login'); }
return resolve(event);};When the refresh token expires
Section titled “When the refresh token expires”Refresh tokens also have a lifetime (often 30-90 days). When the refresh fails:
refreshAccessToken()returnsnullensureFreshToken()callslogout()to clear the invalid session- The user is redirected to login
This is the correct behavior — a failed refresh means the user’s grant has been revoked or expired, and they need to re-authenticate.
Refresh token rotation
Section titled “Refresh token rotation”Some OAuth providers rotate refresh tokens on every use (the old refresh token is invalidated when a new one is issued). The refreshAccessToken helper handles this by preferring the new refresh token from the response, falling back to the existing one if the provider doesn’t return a new one:
refreshToken: (data.refresh_token as string) ?? refreshToken,Security considerations
Section titled “Security considerations”- Refresh tokens are secrets. They are stored encrypted in the session cookie (iron-session AES-256-CBC + HMAC). The
httpOnlyflag prevents JavaScript access. - Cookie size. Access tokens (especially JWTs) can be large. If the total session cookie exceeds ~4KB after encryption, the browser will silently drop it. Test with your actual tokens.
- Logout clears tokens. Calling
auth().logout()deletes the session cookie, which removes the access and refresh tokens from the client. If you also need to revoke the tokens server-side, call your OAuth provider’s revocation endpoint before logging out. - Don’t refresh on 401. By the time you get a 401, the user’s request already failed. Proactive refresh (checking
expiresAtbefore the API call) provides a better user experience.