Home/Learn/Full-Stack Integration/Refresh Tokens, Expiry and Logout

Refresh Tokens, Expiry and Logout

Advanced
Auth in the Browser

Short access tokens buy back revocation, at the cost of a refresh flow that has to handle concurrent requests, rotation and theft detection.

Overview

Once access tokens are short-lived, something has to renew them, and that machinery is where the real complexity of token auth lives. Ten parallel requests can each get a 401 at the same instant and each try to refresh, which either stampedes the endpoint or invalidates the token nine times. Refresh tokens should rotate so a stolen one is only useful once, and reuse of a rotated token is the strongest theft signal you will get. None of this is difficult, but all of it has to be written before the first production incident rather than after.

The Pair

What each token is for, and where each one lives.

Short access, long path-scoped refresh
# Access token — short, sent with every request
#   5-15 minutes. In memory on the client, or a Bearer header.
# Refresh token — long, sent only to /auth/refresh
#   7-30 days. httpOnly cookie, Path=/auth/refresh, and stored
#   server-side so it can be revoked.

@router.post("/auth/login")
async def login(body: LoginIn, response: Response):
    user = await authenticate(body.email, body.password)
    access = create_jwt(user.id, ttl=timedelta(minutes=15))
    refresh = secrets.token_urlsafe(48)
    await db.store_refresh(user.id, hash(refresh),          # HASH it —
                           expires=timedelta(days=30))       # it is a credential
    response.set_cookie("rt", refresh, httponly=True, secure=True,
                        samesite="lax", path="/auth/refresh")
    return {"access_token": access, "user": public(user)}

# Path scoping means the refresh cookie is not sent with ordinary API
# calls at all, which shrinks its exposure considerably.

# Store a hash, never the token itself — a leaked database should not
# hand over live sessions.

Rotation and Reuse Detection

Each refresh invalidates the last, and replaying an old one means theft.

Rotate every use; reuse means revoke the family
@router.post("/auth/refresh")
async def refresh(request: Request, response: Response):
    presented = request.cookies.get("rt")
    row = await db.find_refresh(hash(presented))

    if row is None:
        raise HTTPException(401)

    if row.used_at is not None:
        # An already-rotated token was replayed. Either the real user
        # or an attacker holds a copy — you cannot tell which, so
        # revoke the whole family and force a re-login.
        await db.revoke_family(row.family_id)
        log.warning("refresh reuse detected", user_id=row.user_id)
        raise HTTPException(401, "Session invalidated")

    await db.mark_used(row.id)
    new_refresh = secrets.token_urlsafe(48)
    await db.store_refresh(row.user_id, hash(new_refresh),
                           family_id=row.family_id)
    response.set_cookie("rt", new_refresh, ...)
    return {"access_token": create_jwt(row.user_id, ttl=15 * 60)}

# Rotation turns a stolen refresh token into a detectable event
# rather than a silent, month-long compromise.

One Refresh for Many 401s

The client half, and the logout that actually logs out.

A shared refresh promise, and a complete logout
// Ten requests 401 at once. Without care: ten refreshes, nine of
// which are rejected by rotation, and the user is logged out.
let refreshing = null

async function apiFetch(url, options = {}) {
  let res = await fetch(url, { ...options, headers: withAuth(options) })

  if (res.status === 401 && !options._retried) {
    refreshing ??= doRefresh().finally(() => { refreshing = null })
    const ok = await refreshing            // every caller awaits the SAME promise
    if (!ok) { redirectToLogin(); throw new AuthError() }
    res = await apiFetch(url, { ...options, _retried: true })
  }
  return res
}

// Logout — three things, and skipping any one leaves a trace:
async function logout() {
  await post('/auth/logout')     // server revokes the refresh token
  clearAccessToken()             // in-memory
  queryClient.clear()            // or the next user sees cached data
  navigate('/login', { replace: true })
}

// "Log out everywhere" = revoke every refresh row for the user, or
// bump a token_version the access token carries and the server
// compares. Users expect this after a password change.

// Idle timeout: track last activity and expire the session after
// 30 minutes of inactivity — sleeping a laptop must not extend it,
// so compare timestamps rather than relying on a timer.

Key Points to Remember

  • 1Short access tokens plus a long refresh token restore revocation to a stateless scheme
  • 2Scope the refresh cookie by path and store only its hash — it is a credential
  • 3Rotate the refresh token on every use, and treat reuse of a rotated token as theft: revoke the whole family
  • 4Concurrent 401s must share a single in-flight refresh promise or rotation logs the user out
  • 5A complete logout revokes server-side, clears client state and clears the query cache; "log out everywhere" needs a version or a revoke-all

Interview Questions

Sign in to ask Aria
1

Why use a short access token together with a long refresh token?

Medium
2

What is refresh token rotation and what does reuse detection tell you?

Hard
3

How do you handle ten concurrent requests all receiving a 401?

Hard

Ask Aria about Refresh Tokens, Expiry and Logout

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…