Skip to content

Instantly share code, notes, and snippets.

@ReaganS94
Created October 28, 2025 16:38
Show Gist options
  • Select an option

  • Save ReaganS94/ffc888fbc4700cc2f7690dbaf1a5cf4c to your computer and use it in GitHub Desktop.

Select an option

Save ReaganS94/ffc888fbc4700cc2f7690dbaf1a5cf4c to your computer and use it in GitHub Desktop.

Auth tokens explained (access vs refresh)

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:

  1. Why tokens exist at all
  2. Access token (short-lived JWT)
  3. Refresh token (long-lived random string)
  4. Why we need BOTH, not just one
  5. Full lifecycle: login → use app → token expires → refresh → logout
  6. How the frontend knows if you're logged in
  7. Super short summary in plain language

1. Why do we need tokens at all?

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.


2. Access token (short-lived token)

What it is

  • 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.

Where it lives (important)

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:

  • sameSite so the browser only sends the cookie in allowed situations
  • secure in production so it's only sent over HTTPS

Why cookie and not localStorage?

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.

What it's used for

  • The browser automatically sends the accessToken cookie on each request (because we call fetch(..., { 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".

It expires fast

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.


3. Refresh token (longer-lived token)

What it is

  • It's not a JWT.

  • It's just a long random string (we generate it with randomUUID()).

  • We save it in two places:

    1. As an httpOnly cookie in the browser (refreshToken). Same protections: frontend JS can't read it.
    2. In MongoDB in the RefreshToken collection, along with an expiration time (TTL).

TTL quick explainer

  • 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).

What it's used for

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.

Why rotation matters

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.

Why keep refresh tokens in the DB?

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.


4. Why do we need BOTH tokens?

Let's compare different worlds.

Only access token, no refresh token

  • 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.

Only refresh token, no access token

  • 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 + refresh (the real pattern)

  • 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.


5. Full lifecycle walkthrough

Let's simulate a real session and name the key moments.

5.1 Login

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 (accessToken and refreshToken), using credentials: "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".

5.2 Normal app usage

When the frontend calls the travel API (like GET /posts), it includes the accessToken cookie automatically.

The travel API:

  • verifies the accessToken signature using the shared secret
  • checks the expiry timestamp in the token
  • reads the userId and maybe roles
  • returns data if valid

This does not hit the database for auth. It's fast.

5.3 Access token expires

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

That header literally means:

"Your hall pass expired. Go to the auth server and get a fresh one."

5.4 Refresh (freshly baked cookies)

The frontend now calls /auth/refresh on the auth server. The browser sends the refreshToken cookie automatically.

Auth server:

  1. Looks up that refresh token string in Mongo.

    • If it's missing or expired → reject. You're effectively logged out.
  2. 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.

5.5 Logout

User clicks "Logout".

Frontend calls DELETE /auth/logout with credentials: "include".

Auth server:

  • reads the refreshToken cookie
  • 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.


6. How the frontend knows if you're logged in

We built an AuthContext in the frontend. It does two main jobs:

  1. Hydrate on load

    • On app startup, it calls /auth/me with credentials: "include".

    • /auth/me checks the accessToken cookie.

      • If the token is valid: it returns your profile (id, email, etc.). We save that in React state as user. Navbar shows "Logout", etc.
      • If the token is expired: /auth/me responds with 401 and sets the header WWW-Authenticate: token_expired.

    At that point the frontend could call /auth/refresh behind the scenes, get fresh cookies, then call /auth/me again and set user. (This is the same refresh flow as 5.4.)

  2. 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.

Important:

  • The actual auth truth is in the cookies (access + refresh).
  • The React user state is just the UI mirror of that truth.
  • If cookies die or get cleared, /auth/me will stop returning a user, and the context will update to user = null.

7. The cartoon version (super plain language)

When you log in:

  • 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."

When you call APIs:

  • You flash the short pass (access token). They let you in.

After ~15 minutes:

  • 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

When you log out:

  • 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.


8. Why this is standard and not overkill

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment