Access Tokens and Refresh Tokens: Why You Need Both
December 7, 2025
The two-token pattern exists because one token has to do two contradictory jobs. It has to be sent on every API call, which means a lot of surface area for theft. It also has to keep the user logged in for a long time, which means a long lifetime. You cannot have both safely. So you split them.
The access token is short-lived, usually five to fifteen minutes. It is a bearer credential, often a JWT, and it carries the claims your APIs need: user ID, scopes, roles, an expiry. Every API call attaches it. If it leaks, the attacker has a window measured in minutes before it stops working.
The refresh token is long-lived, days or weeks. It only ever leaves storage to talk to one endpoint: the auth server's token exchange. The auth server validates it, optionally rotates it, and hands back a fresh access token. The refresh token never touches your application APIs, never appears in a log line, never gets read by JavaScript that does not need it.
Why short-lived access tokens? Compromise blast radius. JWTs are typically stateless, which means revocation is hard. If you make them last twenty-four hours, a stolen token is good for twenty-four hours. If you make them last ten minutes, the attacker has to keep stealing them, which means they have to keep pwning the client, which is harder than capturing one token once.
Why refresh tokens have to be storage-isolated. They are the long-lived secret. They belong in an httpOnly secure cookie scoped to the auth path, or in platform-secure storage on mobile. They do not belong in localStorage. They do not belong in sessionStorage. They do not belong anywhere a third-party script can read.
Rotation matters. Each refresh exchange returns a new refresh token and invalidates the old one. If the auth server sees the old token used again, it knows a copy got stolen, and it can revoke the family.
The production failure. A team stored both tokens in localStorage because it was simpler than wiring up cookies on a single-page app. They added a third-party analytics script for a marketing campaign. The script had an XSS bug. An attacker injected code that read both tokens from localStorage and exfiltrated them. The access token expired in fifteen minutes. The refresh token did not. The attacker minted fresh access tokens for two days before the security team noticed an anomalous IP pattern and rotated the signing keys.
The fix was structural. Refresh token moved into an httpOnly Secure SameSite=Strict cookie scoped to /auth. Access token stayed in memory only, never written to storage. The third-party script could not see either one. Short-lived plus storage-isolated. Both protections, working together.
Two tokens, two threat models. Access tokens optimize for blast radius on theft. Refresh tokens optimize for user experience. Storing both in the same place collapses both protections into one.
Originally posted on LinkedIn. View original.