Ever wonder why you can stay logged into your mobile banking app for weeks but your work email kicks you out every hour? It’s all about the balancing act between keeping things secure and not making users want to rip their hair out.
In a modern auth setup, we usually use json web tokens (jwts) as access tokens. They’re great because they are stateless, but they have a big problem: if someone steals one, they own that session until it expires. To mitigate this risk, we make them short-lived—sometimes just 5 or 15 minutes. (Why JWTs Valid After Logout: Pentester's Guide to Tokens)
But if we only had access tokens, your users would have to type their password every 15 minutes. That’s a nightmare for a retail app or a healthcare portal where doctors need fast access to patient records.
/token endpoint, swaps the refresh token for a new access token, and the user keeps scrolling.I've seen plenty of teams get these mixed up, but they serve totally different masters. According to Auth0 by Okta, refresh tokens are essentially a way to maintain a session without keeping the user's actual credentials around in the browser or app memory.
read:reports). Refresh tokens usually don't carry permissions; they are just a key to prove you're still "you."HttpOnly cookie or secure storage on a phone so it survives a page reload or app restart.In a finance app, for example, the access token lets you view your balance. If that token expires while you're looking at your spending habits, the refresh token grabs a new one silently. But if you try to transfer $10k, the system might ignore the token and ask for a fresh login or mfa anyway.
Now that we know why we need 'em, let's look at how the actual handshake works under the hood.
Setting up an enterprise-grade identity flow is usually where things get messy, especially when you realize your "standard" oauth setup doesn't handle disconnected users or long-running background tasks very well. If you’ve ever had a retail app crash because a token expired while a user was mid-checkout, you know exactly why we need to get the implementation right the first time.
To even get a refresh token, you gotta ask for it explicitly during the initial auth request. In most oidc providers, this means adding the offline_access scope. Note: You also have to make sure your client registration on the Identity Provider (idp) is configured to permit the "Refresh Token" grant type. If you don't check that box on the server side, the idp will just ignore your scope request and you won't get a token back.
offline_access in the authorization code request./token endpoint. This is where you get the goods: the access token and the refresh token.I've found that using a provider like SSOJet simplifies this because they handle the heavy lifting of enterprise-grade token management, especially when you're dealing with complex B2B SaaS requirements where different tenants might have different session policies.
The trickiest part isn't getting the token; it's using it without breaking the user experience. You don't want to refresh the token on every single api call—that’s just extra latency—but you also can't wait for the app to fail.
A common pattern is to catch a 401 Unauthorized error in your interceptor. When that happens, you pause all outgoing requests, hit the refresh endpoint, and then retry the original request with the new token.
If your dashboard fires off five api calls at once and the token is expired, all five will trigger a refresh. This is a "thundering herd" problem. You need to implement atomic refresh calls using a queue or subscriber pattern.
// Handling the thundering herd with a refresh promise
let isRefreshing = false;
let refreshSubscribers = [];
function subscribeTokenRefresh(cb) {
refreshSubscribers.push(cb);
}
function onRerefreshFetched(token) {
refreshSubscribers.map(cb => cb(token));
refreshSubscribers = [];
}
async function handleTokenRefresh(failedRequest) {
if (!isRefreshing) {
isRefreshing = true;
try {
// The Auth/BFF API handles the exchange, not the Resource API
const { newAccessToken } = await api.post('/auth/refresh');
isRefreshing = false;
onRerefreshFetched(newAccessToken);
} catch (err) {
isRefreshing = false;
redirectToLogin();
return Promise.reject(err);
}
}
// If a refresh is already happening, we wait for it
return new Promise((resolve) => {
subscribeTokenRefresh((token) => {
failedRequest.headers['Authorization'] = 'Bearer ' + token;
resolve(axios(failedRequest));
});
});
}
According to Okta, implementing token rotation—where you get a new refresh token every time you use an old one—is the gold standard for security, but it makes this race condition logic even more critical.
Next, we'll dive into the scary stuff: what happens when these tokens get stolen and how to lock down your storage.
So, you’ve got refresh tokens working. Great. But now you’ve basically handed out a "skeleton key" that stays valid for days or weeks. If that key gets swiped, the attacker has a permanent backstage pass to your user's data.
The smartest way to handle this is Refresh Token Rotation. Instead of one token that lasts forever, the auth server gives you a brand new refresh token every single time you use the old one to get a new access token.
RT_1 to get AT_2, the server marks RT_1 as dead and issues RT_2.This is where things get really cool—and a bit aggressive. If your server sees a refresh token that has already been used (and rotated), it shouldn't just say "access denied." It should panic.
According to Microsoft, a 2023 report on identity security suggests that token theft is a leading cause in session hijacking. (Session Hijacking 2.0 — The Latest Way That Attackers are …) When a used token shows up again, it’s a massive red flag that two different "people" have the same token.
RT_1 is used a second time, the server knows a breach happened. It should immediately revoke the entire token family. "Reuse detection is the only way to stop an attacker who has successfully exfiltrated a long-lived token from a browser's local storage."
Here is a quick look at how you might check for reuse in a node.js/redis backend:
async function refreshMyToken(oldTokenId) {
const tokenStatus = await redis.get(`token:${oldTokenId}`);
if (tokenStatus === 'used') {
// UH OH. Someone is trying to reuse a token.
// Revoke everything for this user id.
await revokeAllSessions(userId);
throw new Error("Security breach detected. Sessions invalidated.");
}
// proceed with normal rotation...
}
This kind of proactive defense is what separates a basic login page from a real enterprise identity system. It’s a bit more work to code, but it saves you from a nightmare pr disaster later.
Next, let's talk about how to actually clear these out when a user hits "logout" so you don't leave any "ghost" sessions hanging around.
Logout is more than just deleting a cookie on the client side. If you don't tell the auth server that the token is dead, an attacker who already stole it can still use it. This is why you need a revocation endpoint.
When a user clicks "logout" in a retail app or a b2b saas dashboard, your backend should hit the identity provider's /revoke endpoint. This invalidates the refresh token immediately.
For web apps, we really shouldn't be putting these in localStorage. Any rogue script or malicious npm package can just scrape it. Instead, use HttpOnly cookies with the Secure and SameSite=Strict flags because javascript can't touch them.
If you want to be even more secure, use the BFF (Backend for Frontend) pattern. In this setup, the browser doesn't even hold the tokens. Instead, a small middle-layer server stores the tokens in a secure server-side session. The browser just gets a session cookie. When the session needs a new access token, the BFF handles the refresh logic server-to-server, keeping the refresh token entirely out of the browser's reach.
HttpOnly cookies or the BFF pattern.I've seen teams struggle with "Global Logout," especially when a user has five different devices. The cleanest way is to maintain a token family or a session id. When one device triggers a logout or a security alert, you can wipe every token associated with that session id in one go.
According to IETF RFC 7009, which is the standard for oauth 2.0 token revocation, the authorization server must provide a way for clients to signal that a token is no longer needed. This is crucial for preventing "ghost sessions" where a user thinks they're logged out, but their api access is still active.
The reality is that identity is never "set it and forget it." You have to balance making it easy for your users to stay logged in while keeping the bad guys out. Using rotation, secure storage, and a solid revocation strategy is how you build a system that people actually trust.
Honestly, it's about being proactive. Don't wait for a breach to realize your tokens are sitting in plain text or that your logout button doesn't actually do anything on the server. Get these basics right, and you're already ahead of 90% of the apps out there.
*** This is a Security Bloggers Network syndicated blog from SSOJet - Enterprise SSO & Identity Solutions authored by SSOJet - Enterprise SSO & Identity Solutions. Read the original post at: https://ssojet.com/blog/what-are-refresh-tokens-implementation-guide-security-best-practices