Goal: to stop freakin' out and finally understand why we have 2 tokens (access + refresh), what each one does, and how the full login/refresh/logout cycle works.
We'll cover:
- Why tokens exist at all
- Access token (short-lived JWT)
- Refresh token (long-lived random string)
- Why we need BOTH, not just one
- Full lifecycle: login → use app → token expires → refresh → logout
- How the frontend knows if you're logged in
- Super short summary in plain language
HTTP is stateless.
That means every request from the browser to the server is a brand new interaction with zero memory. The server does not remember you between requests.
So if you ask the travel API for /posts, the server needs to know:
- Who are you?
- Are you allowed to see this?
But you can't just say "I logged in 2 minutes ago, trust me bro". The server will not trust you.
This means every request must include proof of identity.
That proof is the access token.
-
It's a JWT (JSON Web Token).
-
It looks like a signed note from the auth server saying something like:
{ "userId": "abc123", "roles": ["user"], "exp": 1730000000 } -
It's digitally signed with
ACCESS_JWT_SECRET, which only the auth server knows. -
Other services (like the travel API) can verify the signature to make sure it wasn't tampered with.
We send it to the browser as an httpOnly cookie called accessToken.
httpOnly means JavaScript in the browser cannot read it with document.cookie. This protects it from most XSS attacks.
We also usually set:
sameSiteso the browser only sends the cookie in allowed situationssecurein production so it's only sent over HTTPS
If you put tokens in localStorage, any XSS can just steal them.
If you keep them in httpOnly cookies, JS never directly sees them, so they're harder to steal.
-
The browser automatically sends the
accessTokencookie on each request (because we callfetch(..., { credentials: "include" })). -
The API looks at that token and checks:
- is the signature valid?
- is it expired?
- who's the user?
If it's valid and not expired → request is allowed. No DB lookup needed.
So the access token is your hall pass. On every request you're basically saying "here's my hall pass, let me through".
Ours expires in ~15 minutes. That means if somebody steals it, they can only use it for a short time. This is good for security.
But this causes a problem:
If the access token dies every 15 minutes, wouldn't the user get logged out every 15 minutes?
Yes. unless we add something else.
That "something else" is the refresh token.
-
It's not a JWT.
-
It's just a long random string (we generate it with
randomUUID()). -
We save it in two places:
- As an httpOnly cookie in the browser (
refreshToken). Same protections: frontend JS can't read it. - In MongoDB in the
RefreshTokencollection, along with an expiration time (TTL).
- As an httpOnly cookie in the browser (
- The refresh token doc in Mongo has a field like
expireAt. - MongoDB is told "when this is older than X seconds, delete it automatically".
- That means refresh tokens die on their own (e.g. after 30 days). You don't need a cron job (don't just skip over this if you don't know what a cron job is, open up a new tab and look it up).
When your access token is expired, the frontend can go to /auth/refresh and say:
"Hey, here's my refresh token. Can I get a new access token?"
If the refresh token is valid (meaning: we still have it in the DB and it's not expired), the auth server:
- creates a NEW access token (new ~15 min hall pass)
- creates a NEW refresh token (brand new random string)
- saves that new refresh token in DB with its own TTL
- deletes the old refresh token from DB
- sends both tokens back as cookies again
This process is called refresh token rotation.
Suppose an attacker somehow got a copy of one of your old refresh tokens. As soon as you use that refresh token, the server deletes it and replaces it with a brand new one. The attacker tries to use the stolen one later → server says "nope, that token doesn't exist anymore".
So even refresh tokens are not infinite golden keys. They self-destruct after successful use.
Because this gives us control.
- On logout, we delete your refresh token from the DB.
- After that, even if your browser tries to use the old refresh token, the server won't find it, so no new access token for you.
- You are actually logged out.
This is how we "end a session" server-side.
Let's compare different worlds.
- If it lasts a long time (days): terrible security. If someone steals it, they're you for days.
- If it lasts a short time (minutes): horrible UX. You get logged out constantly.
Lose/lose.
- You'd send the refresh token on EVERY request.
- The server would have to hit the DB on EVERY request to check if it's still valid.
- If someone steals it, game over until you revoke it manually.
Also bad.
- Access token: fast, lightweight, short-lived, self-verifiable by any service.
- Refresh token: long-lived, stored in DB, only used occasionally to get a new access token.
Result:
- Great UX: you stay "logged in" without typing your password again.
- Solid security: stolen access tokens die fast, stolen refresh tokens get rotated and can be revoked.
- Good performance: normal API requests don't need a DB lookup for auth.
This is why modern systems use both.
Let's simulate a real session and name the key moments.
You submit email+password to /auth/login.
Auth server:
-
checks credentials
-
creates:
accessToken(JWT, ~15 min lifetime)refreshToken(uuid, ~30 days lifetime)
-
stores the refresh token in Mongo with a TTL so it auto-expires later
-
sets both as httpOnly cookies in the browser (
accessTokenandrefreshToken), usingcredentials: "include" -
responds with
{ user: {...} }so the frontend can update React state / Navbar right away
Now the browser is holding both cookies quietly. You're "logged in".
When the frontend calls the travel API (like GET /posts), it includes the accessToken cookie automatically.
The travel API:
- verifies the
accessTokensignature using the shared secret - checks the expiry timestamp in the token
- reads the
userIdand mayberoles - returns data if valid
This does not hit the database for auth. It's fast.
After ~15 minutes your accessToken is no longer valid.
You ask the travel API for /posts again. The travel API:
-
tries to verify your access token
-
sees it's expired
-
responds with:
- status
401 Unauthorized - header
WWW-Authenticate: token_expired
- status
That header literally means:
"Your hall pass expired. Go to the auth server and get a fresh one."
The frontend now calls /auth/refresh on the auth server. The browser sends the refreshToken cookie automatically.
Auth server:
-
Looks up that refresh token string in Mongo.
- If it's missing or expired → reject. You're effectively logged out.
-
If it exists:
- delete the old refresh token from Mongo (rotation)
- mint a brand new
accessToken(new 15 min window) - mint a brand new
refreshToken(new uuid, new TTL in DB) - send BOTH back as httpOnly cookies again
Result: we now have freshly baked cookies. The stale cookies are gone. The browser holds brand new accessToken + refreshToken, and the app can keep going without asking the user to log in again.
After this refresh succeeds, the frontend retries the original request (GET /posts) and it now works.
User clicks "Logout".
Frontend calls DELETE /auth/logout with credentials: "include".
Auth server:
- reads the
refreshTokencookie - deletes that refresh token from MongoDB (so it can never mint a new access token again)
- clears both cookies (
accessToken,refreshToken) in the browser
Now:
- Browser doesn't send an access token anymore (we cleared it)
- Browser doesn't have a refresh token anymore
- The server doesn't have any refresh token record for you either
You're fully logged out.
Even if someone somehow still has an old access token, it's expiring in minutes and can no longer be refreshed.
We built an AuthContext in the frontend. It does two main jobs:
-
Hydrate on load
-
On app startup, it calls
/auth/mewithcredentials: "include". -
/auth/mechecks theaccessTokencookie.- If the token is valid: it returns your profile (
id,email, etc.). We save that in React state asuser. Navbar shows "Logout", etc. - If the token is expired:
/auth/meresponds with401and sets the headerWWW-Authenticate: token_expired.
- If the token is valid: it returns your profile (
At that point the frontend could call
/auth/refreshbehind the scenes, get fresh cookies, then call/auth/meagain and setuser. (This is the same refresh flow as 5.4.) -
-
Keep global UI in sync
- When you register or log in successfully, the auth server returns
{ user: {...} }. - The page (Register/Login component) calls
setUser(user)from context immediately. - Navbar re-renders instantly with the correct state — no manual reload.
- When you register or log in successfully, the auth server returns
Important:
- The actual auth truth is in the cookies (access + refresh).
- The React
userstate is just the UI mirror of that truth. - If cookies die or get cleared,
/auth/mewill stop returning a user, and the context will update touser = null.
- Server: "Here's a short pass (access token). Show this to any service when you want stuff."
- Server: "Also here's a VIP wristband (refresh token). Don't show that to random services. Only bring it back to me if the short pass stops working."
- You flash the short pass (access token). They let you in.
-
Your short pass expires.
-
API says "Nope, expired. Go talk to the auth server."
-
You go to the auth server with your wristband.
-
Auth server:
- gives you a brand new short pass
- gives you a brand new wristband
- destroys the old wristband
- Auth server takes your wristband and shreds it.
- Auth server tells the browser to drop both cookies.
- You have no passes left, so you're out.
That’s the whole system.
This gives you:
-
Security
- Stolen access tokens die fast.
- Stolen refresh tokens can’t be reused forever because of rotation and server-side revocation.
-
Good UX
- User doesn’t get asked to re-enter their password every 15 minutes.
-
Performance
- Normal requests don’t hit the DB just to figure out who you are.
This "short-lived access token + long-lived refresh token" combo is basically how modern web auth is done.
You're now dangerous.