A lot of developers hear “JWT Authentication” and think, “Great, I have a token, so everything is secure.”
But the moment that first access token expires, reality hits:
- The user is constantly logged out.
- You’re tempted to create tokens that last for a month (don’t do this!).
- You discover you’ve completely misunderstood Refresh Tokens.
Most modern authentication systems use JWT Access Tokens. A JWT (JSON Web Token) is just a small, self-contained token that holds information like:
- User ID
- Roles and permissions
- Expiration time
The server signs this token with a secret key. The key advantage is that the server doesn’t need to query the database on every single request to know who the user is. The token itself contains the verified information. This makes authentication very fast.
But this speed comes with a big risk: What if the token is stolen?
Anyone who has the token can use it to impersonate the user until it expires. This is why Access Tokens should always be short-lived, with an expiration of:
- 5 minutes
- 15 minutes
- Maybe an hour, at most.
That’s great, but are we going to ask the user to log in every 15 minutes? Of course not. This is where the Refresh Token comes in, and it’s the part most people get wrong.
The True Role of the Refresh Token
The sole purpose of a Refresh Token is to get a new Access Token without forcing the user to log in again.
When a user logs in for the first time, the server should return two things:
- A short-lived Access Token.
- A long-lived Refresh Token.
The Access Token is sent with every API call, usually in the Authorization header. The Refresh Token, however, should only be used on a specific endpoint (e.g., /refresh) when the Access Token has expired.
sequenceDiagram
participant Client
participant Server
Client->>Server: Login with credentials
Server-->>Client: Here's an Access Token (exp: 15m) and a Refresh Token (exp: 7d)
loop For the next 15 minutes
Client->>Server: API Request (with Access Token)
Server-->>Client: API Response
end
Client->>Server: API Request (with expired Access Token)
Server-->>Client: 401 Unauthorized
Client->>Server: POST /refresh (with Refresh Token)
Server-->>Client: Here's a new Access Token (exp: 15m)
Why not just use the long-lived Refresh Token for everything? Because that would be incredibly dangerous. The Refresh Token is the “master key” for the user’s session. If it’s stolen, an attacker can continuously generate new access tokens.
This means the Refresh Token must be protected even more carefully than the Access Token.
The Danger of localStorage
Many developers, especially during development, do this:
localStorage.setItem("refreshToken", token);
And then they move on. This is a huge security hole. Any Cross-Site Scripting (XSS) attack can easily read the contents of localStorage. If an attacker gets your Refresh Token, it’s game over.
A much more secure approach is to store the Refresh Token in an HttpOnly cookie. This is a special type of cookie that cannot be accessed by JavaScript. Even if an attacker manages to inject a script onto your page, they can’t steal the token.
But this introduces a new potential vulnerability: Cross-Site Request Forgery (CSRF), where a malicious site tricks the user’s browser into sending a request (with its cookies) to your API. This is why systems using HttpOnly cookies must also implement CSRF protection, such as SameSite cookie attributes or anti-CSRF tokens.
The Modern Gold Standard: Refresh Token Rotation
So, the Refresh Token is long-lived, but should it live forever? No. A critical security enhancement is Refresh Token Rotation.
Here’s the idea: every time a Refresh Token is used to get a new Access Token, the server also issues a new Refresh Token and invalidates the old one.
graph TD
A[Client has RT_1] --> B{Use RT_1 to get new tokens};
B --> C[Server validates RT_1];
C --> D{Generate AT_2 and RT_2};
D --> E[Invalidate RT_1];
E --> F[Client receives AT_2 and RT_2];
Why is this so powerful? It helps detect token theft.
Imagine an attacker steals Refresh_Token_A. The legitimate user continues to use the app and eventually uses Refresh_Token_A to get a new set of tokens. The server provides Access_Token_B and Refresh_Token_B and invalidates Refresh_Token_A.
A few minutes later, the attacker tries to use their stolen Refresh_Token_A. The server sees a request with an invalidated token. This is a huge red flag! It means a token was likely compromised. The server can then immediately log the user out of all devices and force a password reset.
This is why many modern systems store Refresh Tokens in a database, even though JWTs are “stateless.” You need a way to track their validity, detect reuse, and allow users to “log out from all devices” (which simply means invalidating all their stored refresh tokens).
Naima’s Final Word: JWT is not a magic security bullet. It’s a tool. Real security comes from your strategy around it: your expiration policies, secure storage, HTTPS, token rotation, revocation, and robust XSS/CSRF protection.
In the age of AI, users are entrusting our systems with more and more personal data and agency. An AI assistant that can manage your calendar, book appointments, and make purchases on your behalf is a huge target. The session management that underpins this trust cannot be an afterthought. Understanding the deep mechanics of token-based authentication isn’t just good practice—it’s a prerequisite for building the next generation of secure and reliable applications. That’s the level of engineering rigor we advocate for at 10xdev.blog.