In this article
April 23, 2025
April 23, 2025

Why your app needs refresh tokens—and how they work

Session management is hard. Refresh tokens make it easier—and safer. This guide breaks down how they work, why you need them, and how to avoid common mistakes (with code included).

No one likes logging in over and over. Users want to stay signed in, and developers want to make that happen without opening up massive security holes. That’s where refresh tokens come in.

If you’re working with OAuth 2.0, building a login system, or just trying to wrap your head around how long-lived sessions work, understanding refresh tokens is essential. So let’s break it down.

The basics: What is a refresh token?

When a user logs into your app via OAuth (or OpenID Connect), the auth server usually gives you two tokens:

  • an access token (short-lived, used to access protected APIs), and
  • a refresh token (longer-lived, used to get a new access token when the first one expires)

Think of the access token like a coffee shop punch card. It works great, but once it's full (aka expired), it’s no good anymore. Instead of making the user re-authenticate every time, you quietly hand over a refresh token and say, “Hey, I was here before—can I get a new card?”

The refresh token says, “Yep, this user is still valid. Here’s a new access token.” And boom—your app keeps working, the user stays happy, and no one’s punching passwords in again.

Why not just use long-lived access tokens instead?

It might seem easier to skip refresh tokens entirely and just issue long-lived access tokens—one token to rule them all, right?

But here’s the catch: if a long-lived access token gets compromised, it's game over until it expires. There’s no “undo” button.

Using short-lived access tokens with refresh tokens introduces a layer of defense and visibility:

  • Time-limited risk: Short-lived access tokens (like 15–30 minutes) mean that they act as "disposable passes" and even if one gets leaked, the damage window is tiny.
  • Detect and respond: With refresh token rotation, you can detect if a token is being reused (which suggests theft), and immediately revoke the session or force re-authentication. If you only use access tokens, there’s no such safety net—whoever has it just gets access until the token dies.
  • Separate powers, separate risks: The separation between these two tokens limits what gets exposed and how much damage any one token can do:
    • Access token: Limited use, short life, very active. They are exposed to the open world (APIs, headers, etc.).
    • Refresh token: Sensitive, used sparingly, but powerful (can generate new access). They are kep locked away (server, HTTP-only cookies).

It’s like having a keycard that only works for a few minutes, but a secret code that lets you get a new one whenever you need. You can risk losing your keycard by carrying it around, but you wouldn’t do the same with the secret code that can give someone the power to get new keycards for a longer time.

How it works

Let’s say you’re building a React frontend that talks to an Express backend. Here’s a typical flow:

  1. User logs in using your OAuth provider (Google, WorkOS, etc.).
  2. You get an access_token and a refresh_token from the auth server.
  3. You store the access token in memory (or cookie if you’re careful).
  4. When the access token expires, your app:
    • Sends the refresh token to the auth server.
    • Gets back a new access token.
  5. The user keeps using the app like nothing happened.

Here’s what a refresh request might look like:

	
POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=abc123refresh
&client_id=my-client-id
&client_secret=my-client-secret
	

And the response:

	
{
  "access_token": "new-access-token-456",
  "expires_in": 3600,
  "token_type": "Bearer"
}
	

Nice and simple.

The lifecycle of a refresh token

Let’s see how a refresh token lives (and eventually dies):

  1. Generation: A refresh token is issued by the authorization server during the initial login flow—usually alongside an access token. It’s normally tied to a session.
  2. Storage: Once issued, the refresh token needs to be stored securely. On the client, that means HTTP-only, secure cookies or encrypted storage (e.g., Keychain on mobile). On the server, you might store it in a database with metadata like IP address, user agent, or device fingerprint for. You should encrypt the token at rest or use a hashed token approach (store a hashed version, e.g., SHA-256, and when alidating, hash the incoming token and compare it with the stored hash).
  3. Expiry: Refresh tokens typically live longer than access tokens (think days or weeks), but they should have an expiration policy. That way, even if they’re stolen, they’ll eventually become useless.
  4. Rotation: Every time a refresh token is used to get a new access token, the server should issue a new refresh token and invalidate the old one. This is called refresh token rotation, and it’s your first line of defense against replay attacks. If the same token is reused, the system can flag it as suspicious and revoke the session.

Best practices for refresh tokens

So, you’ve got your refresh tokens working—but there are some crucial best practices to follow if you want to stay secure (and avoid weird bugs down the line).

1. Store refresh tokens securely

Never, ever store a refresh token in localStorage. That’s a one-way ticket to vulnerability town via XSS.

Do this instead:

  • For SPAs: Store in an HTTP-only, secure cookie (set by the backend).
  • For mobile apps: Use secure, encrypted storage (like Keychain on iOS or Keystore on Android).
  • For server-side apps: Just keep it safe on the server. Like passwords, treat refresh tokens as secrets:
    • Store a hashed version (e.g., SHA-256) of the token.
    • When validating, hash the incoming token and compare it with the stored hash.
App type Recommended storage Notes
SPA (e.g., React) Secure HTTP-only cookie Backend should handle the refresh
Mobile (iOS/Android) Secure OS storage Don't store in plain text
Backend (Node.js, Python, etc.) Server memory or DB Easier to keep secure

2. Rotate your refresh tokens

Each time you use a refresh token, the server should:

  • Return a new access token
  • And a new refresh token

Then invalidate the old refresh token. This is called refresh token rotation, and it helps prevent replay attacks. If an attacker steals an old refresh token but it’s already been rotated, it won’t work.

3. Set expiration and revocation logic

Even refresh tokens shouldn’t last forever. You can:

  • Set them to expire after a few days/weeks.
  • Revoke them on logout.
  • Invalidate them if a device looks suspicious.

That way, if something gets compromised, you’ve got guardrails.

4. Avoid long-lived JWTs as refresh tokens

JWTs are self-contained, meaning they carry all their claims (like sub, exp, etc.) inside the token itself — and don’t need to hit the server to validate. While that sounds efficient, it’s dangerous for refresh tokens.

Since JWTs are stateless, once issued, the server has no record of them. So if a refresh token (JWT) is stolen, it can’t be invalidated unless you keep a server-side blacklist, defeating the purpose of using a stateless token.

In addition to that, if a JWT refresh token is leaked, it stays valid until it expires. You can’t rotate it or expire it early without server-side checks.

That’s why it’s recommended to use opaque tokens (i.e., random, meaningless strings like d1d1f8e1-54aa-4e13-bcf5-1aa9d9fa04e5). Opaque tokens have no embedded data and are only valid if stored on the authorization server and looked up.

Common pitfalls (and how to avoid them)

  • Not rotating refresh tokens: If you reuse refresh tokens without rotation, and one gets stolen, that attacker now has a golden key forever.
  • Misunderstanding “silent authentication” in SPAs: Some devs try to refresh tokens silently from the frontend in the browser. This can work, but it has to be done carefully, typically via an iframe or using an authorization code flow with PKCE. Just blindly calling refresh endpoints from a browser is risky.
  • Using LocalStorage for tokens: Just don’t. It’s super vulnerable to XSS, and there’s no real benefit over secure cookies.

Refresh token endpoint example

Let’s see how a refresh token endpoint would look like for a Node.js app. We’ll use:

  • jsonwebtoken for access tokens (JWTs).
  • uuid for generating opaque refresh tokens.
  • bcrypt to hash refresh tokens before saving them in the database.
  • An in-memory DB (Map) for demo purposes — you can swap it with Mongo, Redis, or PostgreSQL.

Install these packages with npm install express jsonwebtoken uuid bcrypt.The high-level flow will look like this:

  1. User logs in → gets JWT + opaque refresh token.
  2. Refresh token is stored in DB (hashed).
  3. User hits /refresh with refresh token → server verifies + rotates.
  4. Old refresh token is invalidated, new one is issued.
	
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid');

const app = express();
app.use(express.json());

const JWT_SECRET = 'your_jwt_secret';
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY_DAYS = 7;

const refreshTokensDB = new Map(); // Key: hashedToken, Value: { userId, expiresAt }

// Simulated user
const user = {
  id: 'user123',
  username: 'johndoe',
  password: 'password123', // in real apps, this should be hashed
};

// Generate Access Token (JWT)
function generateAccessToken(userId) {
  return jwt.sign({ sub: userId }, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRY });
}

// Generate Refresh Token (Opaque, hashed in DB)
async function generateRefreshToken(userId) {
  const token = uuidv4();
  const hashedToken = await bcrypt.hash(token, 10);
  const expiresAt = Date.now() + REFRESH_TOKEN_EXPIRY_DAYS * 24 * 60 * 60 * 1000;
  refreshTokensDB.set(hashedToken, { userId, expiresAt });
  return token;
}

// Helper to find token in DB
async function findRefreshToken(token) {
  for (const [hashed, data] of refreshTokensDB.entries()) {
    const match = await bcrypt.compare(token, hashed);
    if (match) return { hashed, ...data };
  }
  return null;
}

// Login Route
app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  if (username !== user.username || password !== user.password) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const accessToken = generateAccessToken(user.id);
  const refreshToken = await generateRefreshToken(user.id);
  res.json({ accessToken, refreshToken });
});

// Refresh Token Route
app.post('/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  if (!refreshToken) return res.status(400).json({ error: 'Refresh token required' });

  const storedToken = await findRefreshToken(refreshToken);
  if (!storedToken) return res.status(403).json({ error: 'Invalid or expired refresh token' });

  if (Date.now() > storedToken.expiresAt) {
    refreshTokensDB.delete(storedToken.hashed);
    return res.status(403).json({ error: 'Refresh token expired' });
  }

  // Rotate token
  refreshTokensDB.delete(storedToken.hashed);
  const newRefreshToken = await generateRefreshToken(storedToken.userId);
  const newAccessToken = generateAccessToken(storedToken.userId);

  res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
});

// Logout (invalidate refresh token)
app.post('/logout', async (req, res) => {
  const { refreshToken } = req.body;
  const storedToken = await findRefreshToken(refreshToken);
  if (storedToken) refreshTokensDB.delete(storedToken.hashed);
  res.json({ message: 'Logged out' });
});

app.listen(3000, () => console.log('Auth server running on http://localhost:3000'));
	

To test it call POST /login with:

	
{ "username": "johndoe", "password": "password123" }
	

Use the returned refreshToken in:

	
POST /refresh
{
  "refreshToken": "<your_refresh_token>"
}
	

This is, of course, a simplified example and it’s missing a lot of necessary security best practices:

  • Storing tokens in secure HTTP-only cookies (instead of JSON body).
  • Adding device/user-agent/IP metadata to tokens.
  • Limiting how many refresh tokens per user.
  • Using HTTPS and CORS protections.
  • And more.

How WorkOS handles refresh tokens

When a user signs in to your app with WorkOS, a user session is created. Along with the User object, a successful authentication response will include an access token and refresh token.

	
{
  "user": {
    "object": "user",
    "id": "user_01E4ZCR3C56J083X43JQXF3JK5",
    "email": "marcelina.davis@example.com",
    "first_name": "Marcelina",
    "last_name": "Davis",
    "email_verified": true,
    "profile_picture_url": "https://workoscdn.com/images/v1/123abc",
    "created_at": "2021-06-25T19:07:33.155Z",
    "updated_at": "2021-06-25T19:07:33.155Z"
  },
  "organization_id": "org_01H945H0YD4F97JN9MATX7BYAG",
  "access_token": "eyJhb.nNzb19vaWRjX2tleV9.lc5Uk4yWVk5In0",
  "refresh_token": "yAjhKk123NLIjdrBdGZPf8pLIDvK",
}
	

The access token should be stored as a secure cookie in the user’s browser and should be validated by the backend on each request. Refresh tokens should be persisted on the backend in, for instance, a database, or cache.

Once the access token has expired, a new one can be obtained using the refresh token.

A new access token can be obtained by using the authenticate with refresh token endpoint. If the session is still active, a new access token and refresh token will be returned. Refresh tokens are single use, so be sure to replace the old refresh token with the newly generated one.

If you’re using our Next SDK or Remix SDK, all the work of validating access tokens and refreshing expired tokens is handled for you.

Conclusion

Refresh tokens might seem like a small piece of the OAuth puzzle, but they play a huge role in balancing security and user experience. When done right, they let your users stay logged in without constant re-authentication, while keeping your app secure with short-lived access tokens and smart rotation.

Whether you’re building a simple SPA or scaling up an enterprise-grade platform with WorkOS, understanding how to manage refresh tokens is key to building trustworthy, secure auth flows. Store them safely, rotate them often, and always think one step ahead of the attackers.

And if you’re looking to take the pain out of authentication (especially for enterprise users), WorkOS has your back—with out-of-the-box SSO, directory sync, audit logs, and 1,000,000 monthly active users (MAU) for free.

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.