Getting tokens from an OAuthWhat is oauth?An authorization protocol that lets users grant a third-party app limited access to their account on another service without sharing their password. providerWhat is provider?A wrapper component that makes data available to all components nested inside it without passing props manually. is only the beginning. The harder problem is what you do with them afterward. Access tokens expire, refresh tokens can be revoked, and storing credentials insecurely can hand attackers long-term access to your users' accounts. This lesson covers the full lifecycle: secure storage, transparent renewal, and clean revocation.
Access tokens vs refresh tokens
These two tokenWhat is token?The smallest unit of text an LLM processes - roughly three-quarters of a word. API pricing is based on how many tokens you use. types serve different purposes and have very different security requirements:
| Property | Access token | Refresh token |
|---|---|---|
| Lifetime | 15 minutes to 1 hour | Days, months, or until revoked |
| Purpose | Authorize API calls | Obtain new access tokens |
| Format | Usually a JWT | Opaque string |
| Where to store | Server-side session or httpOnly cookie | Encrypted in your database |
| Exposure risk | Low (short-lived) | High (must be protected carefully) |
An access token is like a day-pass wristband at a concert, it works for the day, then it is worthless. A refresh tokenWhat is refresh token?A long-lived credential used solely to obtain new short-lived access tokens without requiring the user to log in again. is like the receipt you can exchange for a new wristband, you need to keep it safe.
TokenWhat is token?The smallest unit of text an LLM processes - roughly three-quarters of a word. API pricing is based on how many tokens you use. storage security
The most common mistake developers make is storing tokens in localStorage. It is convenient, it survives page refreshes, and every OAuthWhat is oauth?An authorization protocol that lets users grant a third-party app limited access to their account on another service without sharing their password. tutorial shows it. It is also a serious security hole because any JavaScript on your page, including injected XSSWhat is xss?Cross-Site Scripting - an attack where malicious JavaScript is injected into a web page and runs in other users' browsers, stealing data or hijacking sessions. scripts, can read it.
// Never do this - localStorage is readable by any script on the page
localStorage.setItem('access_token', token);
localStorage.setItem('refresh_token', token);
// Never return tokens raw to the client
res.json({ accessToken, refreshToken }); // Wrong!Instead, keep tokens on the server and use an opaque sessionWhat is session?A server-side record that tracks a logged-in user. The browser holds only a session ID in a cookie, and the server looks up the full data on each request. cookieWhat is cookie?A small piece of data the browser stores and automatically sends with every request to the matching server, often used for sessions. to identify the user:
// Server-side: store the refresh token encrypted in your database
await db.updateUser(userId, {
oauth_refresh_token: encrypt(refreshToken),
token_expires_at: new Date(Date.now() + expiresIn * 1000)
});
// Set an httpOnly session cookie - JavaScript cannot read this
res.cookie('session', sessionId, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 3600000 // 1 hour
});Refreshing access tokens
When an access tokenWhat is token?The smallest unit of text an LLM processes - roughly three-quarters of a word. API pricing is based on how many tokens you use. expires, you use the refresh tokenWhat is refresh token?A long-lived credential used solely to obtain new short-lived access tokens without requiring the user to log in again. to get a new one without asking the user to log in again. Here is a complete refresh function:
async function refreshAccessToken(userId) {
const user = await db.getUser(userId);
const refreshToken = decrypt(user.oauth_refresh_token);
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
refresh_token: refreshToken,
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
grant_type: 'refresh_token'
})
});
if (!response.ok) {
if (response.status === 400) {
// The refresh token has been revoked or expired
throw new Error('REFRESH_TOKEN_INVALID');
}
throw new Error('Token refresh failed');
}
const tokens = await response.json();
// Google sometimes returns a new refresh token (token rotation)
if (tokens.refresh_token) {
await db.updateUser(userId, {
oauth_refresh_token: encrypt(tokens.refresh_token)
});
}
// Always update the expiration timestamp
await db.updateUser(userId, {
access_token_expires_at: new Date(Date.now() + tokens.expires_in * 1000)
});
return tokens.access_token;
}prompt: 'consent' during authorization or when token rotation is triggered. Store the new one when it arrives; keep the old one otherwise.Silent refresh middlewareWhat is middleware?A function that runs between receiving a request and sending a response. It can check authentication, log data, or modify the request before your main code sees it.
You want users to stay logged in seamlessly. The trick is to refresh tokens proactively, before they expire, rather than reactively after a failed APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. call. A middleware function that runs on every request is a clean place to do this:
async function tokenRefreshMiddleware(req, res, next) {
const userId = req.session.userId;
if (!userId) return next(); // Not logged in, skip
const user = await db.getUser(userId);
const expiresAt = user.access_token_expires_at;
const fiveMinutes = 5 * 60 * 1000;
// Refresh if the token will expire within 5 minutes
if (expiresAt && Date.now() > expiresAt - fiveMinutes) {
try {
await refreshAccessToken(userId);
} catch (error) {
if (error.message === 'REFRESH_TOKEN_INVALID') {
// Token revoked - clear session and ask user to log in again
req.session.destroy();
return res.status(401).json({ error: 'Session expired' });
}
}
}
next();
}
// Apply to all protected routes
app.use('/api', tokenRefreshMiddleware);The 5-minute buffer handles the case where a tokenWhat is token?The smallest unit of text an LLM processes - roughly three-quarters of a word. API pricing is based on how many tokens you use. expires during a slow API call. Without it, you might start a request with a valid token that expires before the response arrives.
TokenWhat is token?The smallest unit of text an LLM processes - roughly three-quarters of a word. API pricing is based on how many tokens you use. rotation
Some providers (and you can implement this yourself too) use token rotation: every time you exchange a refresh tokenWhat is refresh token?A long-lived credential used solely to obtain new short-lived access tokens without requiring the user to log in again., the old one is immediately invalidated and a new one is issued. This limits the damage if an old refresh token leaks, an attacker cannot use it because it has already been replaced.
If your database still has the old refresh token when a new one arrives, something is wrong. You can detect token reuse attacks this way:
async function storeRotatedRefreshToken(userId, newRefreshToken, oldRefreshToken) {
const user = await db.getUser(userId);
const storedToken = decrypt(user.oauth_refresh_token);
if (storedToken !== oldRefreshToken) {
// Token reuse detected - someone may have used a stolen refresh token
// Clear all tokens for this user and force re-authentication
await db.clearTokens(userId);
throw new Error('TOKEN_REUSE_DETECTED');
}
await db.updateUser(userId, {
oauth_refresh_token: encrypt(newRefreshToken)
});
}TokenWhat is token?The smallest unit of text an LLM processes - roughly three-quarters of a word. API pricing is based on how many tokens you use. revocation
When a user logs out, you should do three things: revoke the refresh tokenWhat is refresh token?A long-lived credential used solely to obtain new short-lived access tokens without requiring the user to log in again. with the providerWhat is provider?A wrapper component that makes data available to all components nested inside it without passing props manually. so it cannot be used again, clear it from your database, and destroy the sessionWhat is session?A server-side record that tracks a logged-in user. The browser holds only a session ID in a cookie, and the server looks up the full data on each request..
async function revokeToken(token) {
// Google's revocation endpoint
await fetch('https://oauth2.googleapis.com/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ token })
});
}
app.post('/logout', async (req, res) => {
const userId = req.session.userId;
if (userId) {
const user = await db.getUser(userId);
if (user.oauth_refresh_token) {
try {
await revokeToken(decrypt(user.oauth_refresh_token));
} catch (e) {
// Log but do not block logout if revocation fails
console.error('Failed to revoke token:', e);
}
}
// Always clear from the database regardless of revocation outcome
await db.updateUser(userId, {
oauth_refresh_token: null,
access_token_expires_at: null
});
}
req.session.destroy();
res.clearCookie('session');
res.json({ success: true });
});Making authenticated APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. calls
When you need to call a providerWhat is provider?A wrapper component that makes data available to all components nested inside it without passing props manually. API (like GitHub's API to list a user's repositories), wrap the fetch call in a helper that handles tokenWhat is token?The smallest unit of text an LLM processes - roughly three-quarters of a word. API pricing is based on how many tokens you use. refresh transparently:
class OAuthApiClient {
constructor(userId) {
this.userId = userId;
}
async request(url, options = {}) {
const user = await db.getUser(this.userId);
// Refresh proactively if token expires within 60 seconds
if (Date.now() > user.access_token_expires_at - 60000) {
await refreshAccessToken(this.userId);
}
const freshUser = await db.getUser(this.userId);
const accessToken = decrypt(freshUser.oauth_access_token);
const response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
});
// 401 means the token was rejected - try one refresh and retry
if (response.status === 401) {
const newToken = await refreshAccessToken(this.userId);
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${newToken}`
}
});
}
return response;
}
}
// Usage
const client = new OAuthApiClient(userId);
const response = await client.request('https://api.github.com/user/repos');Quick reference
| Scenario | What to do |
|---|---|
| Access token expired | Use refresh token to get a new one |
| Refresh token invalid (400) | Clear all tokens, return 401, require re-login |
| User logs out | Revoke refresh token with provider, clear database, destroy session |
| Provider sends new refresh token | Store it immediately, discard the old one |
| Token reuse detected | Clear all tokens for the user, log the incident |
| API call returns 401 | Refresh once and retry; if it fails again, force re-login |
// Complete token management system
import crypto from 'crypto';
class TokenManager {
constructor(db, encryptionKey) {
this.db = db;
this.encryptionKey = encryptionKey;
}
encrypt(text) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipher('aes-256-gcm', this.encryptionKey);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
}
decrypt(encryptedData) {
const [ivHex, authTagHex, encrypted] = encryptedData.split(':');
const decipher = crypto.createDecipher('aes-256-gcm', this.encryptionKey);
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
async storeTokens(userId, tokens) {
const updates = {
oauth_access_token: tokens.access_token ? this.encrypt(tokens.access_token) : undefined,
oauth_refresh_token: tokens.refresh_token ? this.encrypt(tokens.refresh_token) : undefined,
token_expires_at: tokens.expires_in
? new Date(Date.now() + tokens.expires_in * 1000)
: undefined
};
await this.db.updateUser(userId, updates);
}
async getValidAccessToken(userId) {
const user = await this.db.getUser(userId);
if (!user.oauth_access_token) {
throw new Error('No access token found');
}
const expiresAt = new Date(user.token_expires_at).getTime();
const fiveMinutes = 5 * 60 * 1000;
// Refresh if expiring soon
if (Date.now() > expiresAt, fiveMinutes) {
return await this.refreshToken(userId);
}
return this.decrypt(user.oauth_access_token);
}
async refreshToken(userId, provider = 'google') {
const user = await this.db.getUser(userId);
if (!user.oauth_refresh_token) {
throw new Error('REFRESH_TOKEN_INVALID');
}
const refreshToken = this.decrypt(user.oauth_refresh_token);
const urls = {
google: 'https://oauth2.googleapis.com/token',
github: 'https://github.com/login/oauth/access_token',
discord: 'https://discord.com/api/oauth2/token'
};
const response = await fetch(urls[provider], {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
refresh_token: refreshToken,
client_id: process.env[`${provider.toUpperCase()}_CLIENT_ID`],
client_secret: process.env[`${provider.toUpperCase()}_CLIENT_SECRET`],
grant_type: 'refresh_token'
})
});
if (!response.ok) {
if (response.status === 400) {
// Clear invalid tokens
await this.clearTokens(userId);
throw new Error('REFRESH_TOKEN_INVALID');
}
throw new Error('Token refresh failed');
}
const newTokens = await response.json();
await this.storeTokens(userId, newTokens);
return newTokens.access_token;
}
async clearTokens(userId) {
await this.db.updateUser(userId, {
oauth_access_token: null,
oauth_refresh_token: null,
token_expires_at: null
});
}
}
// Express middleware for automatic token refresh
export function createTokenRefreshMiddleware(tokenManager) {
return async (req, res, next) => {
if (!req.session?.userId) return next();
try {
await tokenManager.getValidAccessToken(req.session.userId);
next();
} catch (error) {
if (error.message === 'REFRESH_TOKEN_INVALID') {
req.session.destroy();
res.clearCookie('session');
return res.status(401).json({
error: 'Session expired',
code: 'REAUTHENTICATION_REQUIRED'
});
}
next(error);
}
};
}