Managing user sessions in modern web applications often feels like a tug-of-war between security and user experience. If you set a JSON Web Token (JWT) to expire too quickly, users get frustrated by constant login prompts. If you set it to expire in a week, you create a massive security hole because stateless JWTs are difficult to revoke if stolen. The solution is JWT sliding expiration.
Instead of extending the life of a single, dangerous access token, a secure architecture uses a short-lived access token paired with a long-lived, secure refresh token. This guide explains how to design this system to ensure your users stay logged in as long as they are active, without compromising the security of your API. We will focus on the latest security standards, including OAuth 2.1 recommendations and OWASP best practices for session management.
TL;DR — Issue a short-lived JWT (15 mins) and a long-lived, HttpOnly Refresh Token. When the JWT is close to expiring, the client exchanges the Refresh Token for a new JWT and a new Refresh Token (Rotation). If the user is inactive, the Refresh Token eventually expires, requiring a fresh login.
Table of Contents
The Concept of Sliding Expiration
💡 Analogy: Think of a JWT access token like a "Day Pass" to a gym. It’s cheap to check but expires quickly. The Refresh Token is your "Membership Card" kept in a secure vault (HttpOnly cookie). Every time you show up with your Day Pass near closing time, the gym sees you are active and uses your Membership Card to print you a new Day Pass for tomorrow. If you don't show up for a month, your membership eventually lapses.
In a standard "Fixed" expiration model, a user is logged out exactly X minutes after login, regardless of what they are doing. This is disruptive for productivity apps. Sliding expiration solves this by resetting the expiration clock every time the user interacts with the application. However, modifying a JWT's exp claim directly is impossible because JWTs are immutable once signed. To "extend" it, you must issue an entirely new token.
The "sliding" part happens at the Refresh Token level. While the Access Token remains short-lived (e.g., 15 minutes), the Refresh Token has a longer window (e.g., 7 days). Every time the client uses the Refresh Token to get a new Access Token, the server can also issue a new Refresh Token with a fresh expiration date. This creates a continuous session that only ends when the user stops using the app for a duration longer than the Refresh Token's lifespan.
When to Use Sliding Sessions
Sliding expiration is ideal for applications where user engagement is prolonged and frequent. For instance, in a Project Management Dashboard or a Web-based IDE, a hard logout at the 60-minute mark would cause users to lose unsaved work or break their flow. By implementing a sliding window, you ensure that as long as the developer is typing or clicking, the session remains valid. This mimics the behavior of traditional stateful sessions but scales horizontally across distributed microservices.
However, this pattern is not universal. High-security applications, such as banking portals or cryptocurrency exchanges, often prefer Absolute Expiration. In those environments, even if the user is active, the system forces a re-authentication after a set period (e.g., 30 minutes) to mitigate the risk of a hijacked physical device. When choosing your strategy, evaluate the sensitivity of the data. If the "cost" of an unauthorized session is catastrophic, favor absolute timeouts over sliding windows.
Architecture and Data Flow
The core of a secure sliding JWT implementation relies on separating the storage of the two tokens. The Access Token is usually stored in memory (JavaScript variable) to prevent XSS, while the Refresh Token is stored in an HttpOnly, Secure, and SameSite=Strict cookie. This prevents client-side scripts from reading the refresh token, significantly reducing the surface area for token theft.
[ Client ] [ API Gateway / Auth ] [ Database ]
| | |
|-- 1. Login Credentials ->| |
|<- 2. JWT + Refresh Cookie-| |
| |------- 3. Store Refresh ID -->|
| | |
|-- 4. Request + JWT --->| |
|<- 5. 401 Unauthorized ---| (JWT Expired) |
| | |
|-- 6. /refresh + Cookie ->| |
| |------- 7. Validate & Rotate ->|
|<- 8. New JWT + Cookie -- | |
When the client detects that a JWT is about to expire (or receives a 401 response), it hits a specific /refresh endpoint. The server checks the cookie, verifies the Refresh Token against a database or whitelist, and if valid, generates a new pair. Crucially, the old Refresh Token should be invalidated immediately. This is known as Refresh Token Rotation, and it is a critical defense against replay attacks. If an attacker steals a refresh token and uses it, the real user's subsequent attempt to refresh will fail, alerting the system to a potential breach.
Implementation Steps
Step 1: The Initial Authentication
Upon successful login, the server generates two tokens. The Access Token contains user claims (roles, permissions) and is signed with a private key. The Refresh Token is a random, high-entropy string (opaque token) stored in your database. This allows you to revoke the session instantly by deleting the database entry.
// Example Node.js/Express implementation
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true, // Requires HTTPS
sameSite: 'Strict',
path: '/api/auth/refresh', // Limit cookie scope
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.json({ accessToken, expiresIn: 900 }); // 15 minutes
Step 2: Client-Side Interception
In your frontend (React, Vue, or Angular), use an axios interceptor to handle 401 errors. Instead of redirecting to the login page immediately, the interceptor calls the refresh endpoint. If the refresh succeeds, it retries the original failed request with the new Access Token. This makes the "sliding" experience completely invisible to the user.
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const newAccessToken = await refreshAccessToken();
axios.defaults.headers.common['Authorization'] = `Bearer ${newAccessToken}`;
return axios(originalRequest);
}
return Promise.reject(error);
}
);
Step 3: Server-Side Refresh Logic
The /refresh endpoint must perform a "Rotation." It should verify the existing Refresh Token, look up the associated user, generate a new Refresh Token, update the database, and send the new cookie back to the client. In my experience, implementing a "grace period" of 10-30 seconds where the old refresh token is still accepted can prevent race conditions caused by concurrent frontend requests.
Security Trade-offs and Risks
The primary risk with sliding expiration is the extended window of opportunity for an attacker. If a user's device is compromised, a sliding session could theoretically last forever. To mitigate this, you must implement Max Session Lifetime. Even with sliding expiration, set an absolute hard limit (e.g., 30 days) after which the user *must* re-authenticate with their password, regardless of activity levels.
⚠️ Common Mistake: Do not store the Refresh Token in localStorage. LocalStorage is vulnerable to Cross-Site Scripting (XSS). If an attacker can run a single script on your page, they can steal the refresh token and maintain access to the user's account indefinitely. Always use HttpOnly cookies for refresh tokens.
Another trade-off is the increased load on your database. Unlike purely stateless JWTs, sliding expiration requires a database check at every refresh interval (e.g., every 15 minutes). For most applications, this is a negligible cost compared to the security benefits of being able to revoke sessions. However, at extreme scales, you should use a high-performance key-value store like Redis to manage your Refresh Token whitelist.
Pro-Tips for Production Scale
When deploying this to production, consider Fingerprinting your tokens. You can hash the user's IP address or User-Agent and include it in the Refresh Token's metadata. If the IP address suddenly changes from New York to Moscow during a refresh attempt, you can trigger a security challenge or invalidate the session immediately. This provides an extra layer of defense against session hijacking.
Additionally, monitor your "Refresh Token reuse" logs. If your server receives a request with a Refresh Token that has already been rotated (invalidated), this is a 100% indicator of a breach or a serious bug. In this scenario, the safest move is to invalidate all active sessions for that specific user. This "nuclear option" ensures that even if an attacker successfully performed a replay, their access is cut off the moment the legitimate user attempts to sync.
Finally, keep your libraries updated. JWT security is an evolving field, and vulnerabilities in libraries like jsonwebtoken or jose are discovered periodically. Using modern frameworks that follow the OAuth 2.1 draft will help you avoid "None" algorithm attacks and other common pitfalls in JWT implementation.
Frequently Asked Questions
Q. Is sliding expiration for JWT secure?
A. Yes, provided you use Refresh Token Rotation and store the refresh token in an HttpOnly, Secure cookie. This setup ensures that the long-lived token is inaccessible to XSS attacks and that stolen tokens have a very limited window of utility before they are invalidated by the rotation mechanism.
Q. What is the difference between sliding and absolute expiration?
A. Absolute expiration terminates the session after a fixed time from the initial login, regardless of activity. Sliding expiration resets the expiration timer whenever the user performs an action, allowing the session to stay alive as long as the user remains active and engaged with the app.
Q. Can I implement sliding expiration without a database?
A. Technically, you could embed the "sliding" logic inside a stateful JWT, but this is highly discouraged. Without a database or a revocation list, you cannot revoke a compromised token. Secure sliding expiration requires the server to track the Refresh Token's validity, which necessitates a storage layer like Redis or PostgreSQL.
📌 Key Takeaways
- Access Tokens should be short-lived (15-30 mins) and stored in memory.
- Refresh Tokens should be long-lived, HttpOnly, and stored in a database for revocation.
- Implement Token Rotation: every refresh should issue a new Refresh Token.
- Use a hard "Max Session Age" to force re-authentication periodically.
- Monitor for token reuse as a signal of a security breach.
Post a Comment