efresh tokens are exclusively a /token endpoint concern.
Step 1: Update the Client Configuration
The client must explicitly declare support for the refresh_token grant and request the offline_access scope.
// Before
const CLIENT = {
id: "example-client",
secret: "example-secret",
grants: ["authorization_code"],
redirectUris: [],
scopes: ["openid", "profile", "email", "content:read", "content:write"],
};
// After
const CLIENT: {
id: string;
secret: string;
grants: string[];
redirectUris: string[];
scopes: string[];
} = {
id: "example-client",
secret: "example-secret",
grants: ["authorization_code", "refresh_token"],
redirectUris: [],
scopes: ["openid", "offline_access", "profile", "email", "content:read", "content:write"],
};
The grants array is validated inside getClient to enforce grant-type authorization. The scopes array dictates what the consent screen exposes to the user. Without "offline_access", the client cannot request a refresh token.
Step 2: Add Refresh Token Storage
Implement a storage layer to track issued refresh tokens and their associated metadata.
// in-memory refresh token storage, mapping refresh tokens to client, user, scope, and expiration
const refreshTokenStorage: Record<
string,
{
clientId: string;
userId: string;
scope: string[];
expiresAt: number;
}
> = {};
Each entry key represents the refresh token value. scope preserves the original consented scopes to enable secure narrowing on renewal. expiresAt is evaluated on every exchange to reject stale credentials.
Production Note: Replace in-memory storage with a persistent backend (Redis, PostgreSQL, etc.) to ensure token durability across restarts and enable centralized revocation.
Step 3: Register offline_access in the Flow Builder
Expose the new scope in the discovery document via .setScopes().
// Before
.setScopes({
openid: "OpenID Connect scope",
profile: "Access to your profile information",
email: "Access to your email address",
"content:read": "Access to read content",
"content:write": "Access to write content",
})
// After
.setScopes({
openid: "OpenID Connect scope",
offline_access: "Request refresh token for offline access",
profile: "Access to your profile information",
email: "Access to your email address",
"content:read": "Access to read content",
"content:write": "Access to write content",
})
Step 4: Update getClient to Handle the Refresh Token Grant
Extend the token endpoint handler to process refresh_token grants alongside authorization_code.
.getClient(async (tokenRequest) => {
// existing authorization_code branch unchanged
if (
tokenRequest.grantType === "authorization_code" &&
tokenRequest.clientId === CLIENT.id &&
tokenRequest.code
) {
// ... same as before
}
// handle the refresh token grant type
if (
tokenRequest.grantType === "refresh_token" &&
tokenRequest.clientId === CLIENT.id &&
CLIENT.grants.includes("refresh_token")
) {
const refreshTokenData = refreshTokenStorage[tokenRequest.refreshToken];
// validate the refresh token and its association with the client
if (!refreshTokenData)
throw new HTTPException(400, {
res: new Response(
JSON.stringify({ error: "invalid_grant", error_description: "Invalid refresh token" }),
{ headers: { "Content-Type": "application/json" } }
),
});
if (refreshTokenData.clientId !== tokenRequest.clientId)
throw new HTTPException(400, {
res: new Response(
JSON.stringify({
error: "invalid_grant",
error_description: "Invalid client for refresh token",
}),
{ headers: { "Content-Type": "application/json" } }
),
});
// for security, remove the used refresh token to prevent reuse (rotate on each use)
delete refreshTokenStorage[tokenRequest.refreshToken];
// check if the refresh token has expired
if (refreshTokenData.expiresAt < Date.now()) {
throw new HTTPException(400, {
res: new Response(
JSON.stringify({
error: "invalid_grant",
error_description: "Refresh token has expired",
}),
{ headers: { "Content-Type": "application/json" } }
),
});
}
// narrow the scope if the client requests a subset
const requestedScope = Array.isArray(tokenRequest.scope) ? tokenRequest.scope : [];
const accessScope = requestedScope.length
? refreshTokenData.scope.filter((s) => requestedScope.includes(s))
: refreshTokenData.scope;
return {
id: CLIENT.id,
grants: CLIENT.grants,
redirectUris: CLIENT.redirectUris,
scopes: CLIENT.scopes,
metadata: {
accessScope,
userId: refreshTokenData.userId,
username: USER.username,
userEmail: USER.email,
userFullName: USER.fullName,
},
Pitfall Guide
- Skipping Token Rotation: Failing to invalidate the old refresh token immediately upon exchange enables replay attacks. Always delete or cryptographically rotate the token on every successful grant.
- Relying on In-Memory Storage in Production: In-memory maps lose state on server restarts and prevent cross-node revocation. Migrate to Redis or a relational database with indexed
refresh_token lookups.
- Ignoring Scope Narrowing Validation: Allowing clients to request scopes broader than the original consent violates least-privilege principles. Always intersect requested scopes with the original token's scope array.
- Omitting
offline_access Scope Declaration: Clients will silently fail to receive refresh tokens if the scope isn't registered in .setScopes() and explicitly consented to by the user.
- Neglecting Client Association Checks: Failing to verify
refreshTokenData.clientId === tokenRequest.clientId allows cross-client token hijacking. Always enforce strict client-binding.
- Overlooking Expiration Enforcement: Validating only the token signature without checking
expiresAt leaves the system vulnerable to stale token abuse. Always compare against Date.now() before issuance.
Deliverables
- π OIDC Refresh Token Architecture Blueprint: Complete sequence diagram mapping the Authorization Code β Consent β Access/Refresh Token issuance β Silent Renewal flow, including rotation boundaries and scope narrowing logic.
- β
Pre-Deployment Verification Checklist: 12-point audit covering grant registration, scope consent flow, token expiration thresholds, rotation enforcement, client-binding validation, and persistent storage migration steps.
- βοΈ Configuration Templates: Ready-to-use Hono flow builder snippets, PostgreSQL/Redis schema definitions for refresh token persistence, and client configuration manifests supporting
offline_access and refresh_token grants.