When I first heard about refresh tokens, I genuinely didn't understand why they existed. We already have a token. Why do we need another token to get more tokens?
Felt like overkill.
Then someone explained the actual reason and it made complete sense. I just needed the right analogy.
The Problem With Just One Token
Imagine you give someone a key to your house. They can come in whenever they want. Great for convenience.
But what if they lose that key? Or someone steals it? Now you have a problem. You either have to change your locks (log everyone out) or just hope nobody finds the lost key.
That's what a single long-lived JWT token looks like. Convenient. But risky.
The fix is to use two tokens with very different jobs.
What an Access Token Does
The access token is the one your app actually uses to make API requests. Every time your frontend talks to your server — getting user data, posting content, loading a dashboard — it sends the access token.
It's short-lived. Usually 15 minutes to an hour.
That short life is the whole point. If someone steals this token, they only have a small window to use it before it expires and becomes useless.
// Access token — short lived, used constantly
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' }
);
Think of it like a visitor badge at an office. It lets you through the doors while you're there. But it stops working at the end of the day.
What a Refresh Token Does
The refresh token has one job. It gets you a new access token when the old one expires.
That's literally it. It doesn't open doors or make API requests. It just goes to one specific endpoint and says "hey, my access token died, give me a fresh one."
Because its job is so limited, it can be long-lived. 7 days. 30 days. Sometimes longer.
// Refresh token — long lived, used rarely
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
Back to the office analogy. The refresh token is like your employment contract. You don't use it to open doors — you use it when your visitor badge expires to get a fresh one from HR.
Why They Need Separate Secret Keys
Notice the two different secrets in the code above — JWT_ACCESS_SECRET and JWT_REFRESH_SECRET.
That's not an accident. You need two completely different keys.
If you use the same secret for both, a compromised access token could potentially be used as a refresh token too. The whole point of separation falls apart.
I always generate both keys separately at jwtsecretkeygenerator.com — one click for the access secret, one click for the refresh secret. Two fresh, completely different, cryptographically secure keys. Takes about 20 seconds.
How They Work Together in Real Life
Here's the full flow from the moment a user logs in:
- User logs in → server issues both tokens → client stores both
- Client makes API request → sends access token in the header
- Server checks access token → request goes through
- 15 minutes pass → access token expires
- Next API request fails with 401 (unauthorized)
- Client detects 401 → silently sends refresh token to /api/refresh
- Server verifies refresh token → issues a brand new access token
- Client retries the original request with the new access token
- User never notices any of this happened
From the user's side, they just stay logged in. No interruption. No "please log in again" pop-ups every 15 minutes.
That's the magic of the two-token system.
Side-by-Side: The Key Differences
| Feature | Access Token | Refresh Token |
|---|---|---|
| Purpose | Access protected API routes | Get new access tokens |
| Expiry | Short (15 min – 1 hour) | Long (7 – 30 days) |
| Sent with requests | Every API request | Only to /refresh endpoint |
| Storage | Memory or localStorage | HttpOnly cookie (preferred) |
| Secret key | JWT_ACCESS_SECRET | JWT_REFRESH_SECRET |
| Server-side storage | Not needed | Should be stored for revocation |
| If stolen | Useless after 15 minutes | Can get new access tokens for days |
Where Should You Store Each One?
This is where it gets a bit nuanced but it's worth getting right.
Access token: JavaScript memory (a variable in your app) is safest. localStorage works too but is slightly more exposed to XSS attacks. Since it expires fast anyway, the risk is manageable.
Refresh token: An HttpOnly cookie is the best option. HttpOnly means JavaScript can't read it at all. Only the browser sends it automatically with requests to your server. Even a full XSS attack can't steal it.
// Set refresh token as HttpOnly cookie on login
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // JS can't read this
secure: true, // HTTPS only
sameSite: 'strict', // Only sent to your domain
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days in ms
});
// Send access token in response body
res.json({ accessToken });
The refresh token rides in a cookie silently. The access token lives in your app's memory. Attacker can't steal the refresh token through XSS, and the access token expires before it can do much damage.
What If the Refresh Token Gets Stolen?
This is a fair concern. A 7-day or 30-day token is more valuable to steal than a 15-minute one.
There are a few things that limit the damage:
- HttpOnly cookies are very hard to steal through normal XSS attacks
- You store refresh tokens server-side, so you can revoke them
- Refresh token rotation (issuing a new refresh token on every use) means a stolen token becomes invalid after one use
- Detecting reuse of an old refresh token can alert you to a theft
Refresh token rotation is the gold standard for this. Every time someone uses a refresh token, you invalidate the old one and issue a new one. If an attacker steals an old refresh token and tries to use it later, your server sees it's already been used and locks down the account.
Do You Always Need Both?
Not always. It depends on your app.
If you're building a simple internal tool, a single access token with a longer expiry (a few hours) might be totally fine. The security tradeoff is worth the simpler setup.
If you're building anything with real users who expect to stay logged in, or anything handling sensitive data — use both. The extra setup takes maybe an hour and the benefits are real.
It's not that complicated once you understand what each token is actually for. One works hard every request, expires fast, and limits damage if stolen. The other sits quietly, lasts longer, and just keeps things running smoothly in the background.
Two tokens. Two jobs. That's all it is.
💡 Quick Setup Checklist: Generate two separate secrets (access + refresh) → Issue both on login → Store access token in memory, refresh token in HttpOnly cookie → Handle 401 errors by silently refreshing → Delete refresh token server-side on logout. Done.