The previous articles in this series built a working OIDC Authorization Code Flow server, fixed the hardcoded issuer, discussed persistent signing keys, and added a consent screen. Now it's time to tackle the refresh token grant.
Access tokens are short-lived by design (~30 minutes - 90 minutes). Once they expire, the client needs a new one. Without refresh tokens, that means sending the user back through the login and consent flow every hour. That makes a TERRIBLE experience for long-lived sessions like a mobile app or a background service. Can you imagine having to log in (user + password) every hour just to open your email app?
The refresh token grant solves this.
After the user authenticates and grants consent, the server issues a long-lived refresh token alongside the short-lived access token. The client stores the refresh token securely and exchanges it for a new access token whenever the old one expires. No user interaction required.
The offline_access scope controls whether a refresh token is issued. Clients that need to operate when the user is not actively present request it explicitly, which also ensures the user sees it on the consent screen before agreeing.
FYI: I didn't come up with the name offline_access. It is in the OpenID Connect specification.
TL;DR
The full runnable example is available at Github (apps/oidc-refresh-app).
What changes
Three areas of the server need updating:
| Area | Change |
|---|---|
| Client config | Add "refresh_token" to authorized grants and "offline_access" to authorized scopes
|
| Storage | Save the refresh tokens and their metadata (client, user, scope, expiration) |
| Flow builder | Register the new scope, extend getClient, update generateAccessToken, add generateAccessTokenFromRefreshToken
|
The authorization and consent endpoints are untouched. Refresh tokens are entirely a token-endpoint concern.
Step 1: Update the client configuration
The client needs two additions: the refresh_token grant type and 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 checked inside getClient to ensure the client is actually allowed to use the refresh token grant. The scopes array is what the consent screen will offer to the user. Without "offline_access" in scopes, the client cannot request it.
Step 2: Add refresh token storage
Add an in-memory map to hold issued refresh tokens:
// 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 is the refresh token value. scope records what scopes the token was issued for, so a narrowed scope can be enforced on renewal. expiresAt is checked on every use to reject stale tokens.
In production, replace this with a persistent store (Redis, PostgreSQL, etc.) so refresh tokens survive restarts and can be revoked centrally.
Step 3: Register offline_access in the flow builder
Add the new scope to .setScopes() so it appears in the discovery document:
// 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
getClient is called for every POST to /token. Previously it only handled authorization_code. Now it needs a second branch for refresh_token.
.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,
},
};
}
})
A few things worth noting:
-
Rotation on every use:
delete refreshTokenStorage[tokenRequest.refreshToken]runs before the expiry check. This is intentional. A used token is always removed, whether it is valid or not. -
Scope narrowing: a client may request a subset of the original scopes on renewal (e.g., drop
content:writefor a read-only operation). If the client sends noscopeparameter, the full original scope is preserved. -
Error format: throwing an
HTTPExceptionwith a JSON body lets us return the exactinvalid_granterror code the OAuth 2 spec requires, without relying on the flow's default error handling. This feels very manual, but it works.
Step 5: Issue a refresh token in generateAccessToken
generateAccessToken is called the first time tokens are issued (right after the authorization code exchange). Update it to conditionally generate and store a refresh token when offline_access is in the granted scope:
.generateAccessToken(async (grantContext) => {
// ... sign accessToken and idToken as before ...
// generate the refresh token if the "offline_access" scope was requested,
// and store it in the refresh token storage with an expiration time
const refreshToken = (() => {
if (accessScope.includes("offline_access")) {
return crypto.randomUUID();
}
return undefined;
})();
if (refreshToken) {
refreshTokenStorage[refreshToken] = {
clientId: grantContext.client.id,
userId: `${grantContext.client.metadata?.userId}`,
scope: accessScope,
expiresAt: Date.now() + 30 * 24 * 3600 * 1000, // 30 days
};
}
// return the refresh token in the token response
return {
accessToken,
scope: accessScope,
idToken,
refreshToken, // undefined if offline_access was not requested
};
})
refreshToken is undefined when offline_access was not in the requested scope. The flow omits undefined fields from the token response, so clients that didn't ask for a refresh token simply won't receive one.
Step 6: Add generateAccessTokenFromRefreshToken
This new callback mirrors generateAccessToken but is called when the grant type is refresh_token. The grantContext is populated from the metadata returned by getClient in Step 4 (the same pattern used for the authorization code grant).
// generate access token from refresh token, reusing the same claims structure
// and signing method as the initial access token
.generateAccessTokenFromRefreshToken(async (grantContext) => {
const accessScope = Array.isArray(grantContext.client.metadata?.accessScope)
? grantContext.client.metadata.accessScope
: [];
const registeredClaims = {
exp: Math.floor(Date.now() / 1000) + grantContext.accessTokenLifetime,
iat: Math.floor(Date.now() / 1000),
nbf: Math.floor(Date.now() / 1000),
iss: grantContext.origin,
aud: grantContext.client.id,
jti: crypto.randomUUID(),
sub: `${grantContext.client.metadata?.userId}`,
};
const { token: accessToken } = await jwksAuthority.sign({
scope: accessScope.join(" "),
...registeredClaims,
});
const { token: idToken } = await jwksAuthority.sign({
username: `${grantContext.client.metadata?.username}`,
name: accessScope.includes("profile")
? `${grantContext.client.metadata?.userFullName}`
: undefined,
email: accessScope.includes("email")
? `${grantContext.client.metadata?.userEmail}`
: undefined,
...registeredClaims,
});
// rotate: issue a new refresh token to replace the one consumed in getClient
const refreshToken = (() => {
if (accessScope.includes("offline_access")) {
return crypto.randomUUID();
}
return undefined;
})();
if (refreshToken) {
refreshTokenStorage[refreshToken] = {
clientId: grantContext.client.id,
userId: `${grantContext.client.metadata?.userId}`,
scope: accessScope,
expiresAt: Date.now() + 30 * 24 * 3600 * 1000, // 30 days
};
}
return {
accessToken,
scope: accessScope,
idToken,
refreshToken,
};
})
The token-signing logic is identical to generateAccessToken. The only difference is that grantContext.client.metadata was populated from the refresh token data (no nonce because OIDC nonces are one-time values tied to the original authorization request).
How the full flow works
Here is the complete sequence for a client that requests offline_access:
- The client redirects the browser to
GET /authorize?scope=openid+offline_access+.... - The user logs in, sees
offline_accesson the consent page, and clicks Allow. - The server issues an authorization code and redirects back to the client.
- The client POSTs the code to
/token→ receivesaccess_token,id_token, andrefresh_token. - The client stores the refresh token securely.
- When the access token expires, the client POSTs to
/tokenwithgrant_type=refresh_tokenand the storedrefresh_token. -
getClientvalidates the token, rotates it (deletes the old one), and returns the client metadata. -
generateAccessTokenFromRefreshTokensigns a new access token and a new refresh token. - The client replaces both stored tokens and continues.
Security considerations
-
Token rotation is already implemented: every refresh removes the old token before issuing a new one. If an attacker intercepts and uses a token first, the legitimate client's next attempt will fail with
invalid_grant, alerting it that the token may have been stolen. - Expiry: refresh tokens are stored with a 30-day TTL. After that, the user must authenticate again.
- Scope restriction: clients cannot request more scopes on renewal than were originally granted.
- In production, store refresh tokens in a database with an index on both the token value and
(clientId, userId)so you can revoke all tokens for a user (or a specific client) in one query.
What's next
Well, I think I said enough for authorization code flow. Of course, there are many improvements and additional features you could add on top of this basic implementation:
- Persist clients, users, codes, refresh tokens, and session cookies in a database instead of in-memory objects
- Add a registration endpoint (
POST /register) per RFC 7591 - etc...
@saurbit/oauth2 also supports other grant types like client_credentials and device_authorization. If you're building a machine-to-machine API, the client credentials grant allows a service to authenticate without a user context. The device authorization grant is perfect for headless clients like smart TVs or CLI apps that can't display a browser for login.
We could go on and on, but I think the core concepts are clear now. You can build on top of this foundation to create a fully featured authorization server that meets your specific needs.
The series won't stop here though. With time, I might add more articles, less focused on the basics (which is why I had to talk about the basics in the first place).
@saurbit/oauth2 can be used with many frameworks and is not tightly coupled to Hono. @saurbit/hono-oauth2 provides a convenient adapter for Hono, but the core logic lives in @saurbit/oauth2 and can be reused across different environments. If you're using Express, Fastify, Oak, or any other JavaScript framework, you can still use the same flow builder and callbacks to implement your authorization server logic. The main difference will be how you integrate the flow into your HTTP routes and middleware. @saurbit/oauth2 expects Web standard API objects so as long as your framework can provide those (or you can adapt them), you can use the same core logic.
Rambling note
I always wanted to touch on the topic of OpenID Connect and OAuth 2.0 servers in JavaScript, and gave up many times because it felt overwhelming. The specs are long and complex, and there are many edge cases and security considerations to cover. I only hope I was able to present the material in a clear and (kind of) approachable way, without oversimplifying the important details. If you have any questions or suggestions for future articles, feel free to reach out! And thank you for reading this far. I hope it was helpful and not too boring.

Top comments (0)