Recommended resources
The freely available resources this lesson will direct you to.
- JWT Logout: The Trick You Missed in Spring Security (Solved!)www.youtube.com
- JWT Logout View with Refresh Token BlackList. Django Rest Framework Project Tutorial [24]www.youtube.com
- How to Invalidate a JWT Token After Logout: Risks & ...www.descope.com
- Revoke Access Using a JWT Blacklistsupertokens.com
Hello! Let's get started on the final piece of our JWT session management module.
In our last lesson, we built a sophisticated client-side interceptor to handle concurrent requests during a token refresh. This made our application's session handling robust and efficient from the client's perspective. Today, we'll complete the picture by implementing the server-side logic for a secure logout.
This lesson directly addresses the learning outcome: Implement a secure logout mechanism that invalidates both access and refresh tokens on the server. We'll explore why JWT logout requires a server-side strategy and then implement a "blacklist" to ensure that once a user logs out, their session is truly terminated.
1. The Challenge of Stateless Logout
In a traditional session-based system, logging out is simple: the server finds the user's session in its database or cache and deletes it. Any subsequent request with that session ID is invalid.
JWTs, by design, are stateless. The server doesn't store them. It validates a token's signature and expiration date on each request. This is great for scalability but creates a challenge for logout. If a user clicks "logout" and the client simply deletes its tokens, what happens if an attacker has already stolen them? The stolen tokens remain valid until they expire, posing a significant security risk.
To understand this fundamental challenge, please watch the beginning of the following video.
JWT Logout: The Trick You Missed in Spring Security (Solved!)
The video 'JWT Logout: The Trick You Missed' provides an excellent visual explanation of the difference between session-based and JWT-based authentication, clarifying why JWT logout is a unique problem.
Watch the first 4 minutes and 30 seconds of the video. Focus on the distinction between how session-based and JWT-based systems handle requests and why the stateless nature of JWTs makes immediate invalidation difficult.
The key takeaway is that to achieve a secure logout, we must introduce a small amount of state on the server to track tokens that should no longer be trusted, even if they are not yet expired.
2. The Solution: Blacklisting the Refresh Token
Our authentication system uses two tokens:
- Access Token: Short-lived (e.g., 15 minutes). Used to access protected resources.
- Refresh Token: Long-lived (e.g., 7 days). Used only to get a new access token.
While an attacker could use a stolen access token for its short lifespan, the real danger lies in the long-lived refresh token, which can be used to generate new access tokens indefinitely. Therefore, our primary goal during logout is to invalidate the refresh token.
The most common and effective strategy for this is token blacklisting. The process works as follows:
- Logout Request: The client sends a request to a dedicated
/logoutendpoint. - Blacklist Entry: The server receives the request, extracts the refresh token (from the HTTP-only cookie), and adds its unique identifier to a "blacklist" (or "denylist"). This list is a server-side record of all tokens that have been revoked.
- Cookie Clearing: The server clears the HTTP-only cookies containing the access and refresh tokens.
- Enforcement: The existing token refresh logic is modified. Before issuing a new access token, it must first check if the incoming refresh token is on the blacklist. If it is, the request is rejected with a
401 Unauthorizederror.
This article provides an excellent overview of this strategy.
Revoke Access Using a JWT Blacklist
The article 'Revoke Access Using a JWT Blacklist' from SuperTokens explains several revocation methods. We will focus on the most relevant one for our use case.
Please read the section 'Token Blacklisting'. It clearly explains the pros and cons and provides a Node.js example using Redis, which is a common choice for a high-performance blacklist. Also, briefly review the section on 'Refresh Tokens for Long-Lived Sessions' to see how it reinforces the idea of revoking the refresh token.
For our implementation, we'll use a simple in-memory Set to act as our blacklist. In a production environment, you would replace this with a more persistent and scalable solution like Redis or a database table.
3. Implementing Secure Logout in TanStack Start
Let's integrate this logic into our application. This involves creating a new server function for logout, updating our refresh logic to check the blacklist, and wiring it all up on the client.
Step 1: Create the Blacklist and Logout Server Function
First, we'll create a file to manage our blacklist and the logout logic.
src/lib/auth.server.ts
import { lucia } from './lucia';
import { cookies } from 'vinxi/http';
// In-memory store for revoked refresh tokens.
// In production, use Redis or a database.
const tokenBlacklist = new Set<string>();
/**
* Adds a refresh token to the blacklist.
* @param token The refresh token to blacklist.
*/
export function blacklistRefreshToken(token: string) {
tokenBlacklist.add(token);
// Optional: In a real app, you might store the token's expiry
// to periodically clean up the blacklist.
}
/**
* Checks if a refresh token is blacklisted.
* @param token The refresh token to check.
* @returns True if the token is blacklisted, false otherwise.
*/
export function isTokenBlacklisted(token: string): boolean {
return tokenBlacklist.has(token);
}
/**
* Server function to handle user logout.
* Blacklists the refresh token and clears auth cookies.
*/
export async function logoutUser() {
'use server';
try {
const refreshToken = cookies().get('refresh_token');
if (refreshToken) {
// Add the current refresh token to the blacklist
blacklistRefreshToken(refreshToken);
}
// Use Lucia's utility to create blank cookies, effectively deleting them.
const blankAccessTokenCookie = lucia.createBlankSessionCookie();
const blankRefreshTokenCookie = lucia.createBlankSessionCookie();
// We need to manually set the names to match our existing cookies.
cookies().set(
blankAccessTokenCookie.name,
blankAccessTokenCookie.value,
{ ...blankAccessTokenCookie.attributes, name: 'access_token' }
);
cookies().set(
blankRefreshTokenCookie.name,
blankRefreshTokenCookie.value,
{ ...blankRefreshTokenCookie.attributes, name: 'refresh_token' }
);
return { success: true };
} catch (error) {
console.error('Logout error:', error);
return { success: false, error: 'Logout failed' };
}
}
Step 2: Update the Refresh Logic
Now, we must modify our existing refreshToken server function to consult the blacklist before issuing new tokens.
src/server/auth/refresh.ts (or wherever your refresh logic resides)
import { lucia } from '../../lib/lucia';
import { verify } from '@node-rs/argon2';
import { cookies } from 'vinxi/http';
import { db, users } from '../../db/schema';
import { eq } from 'drizzle-orm';
// Import the blacklist checker
import { isTokenBlacklisted } from '../../lib/auth.server';
export async function refreshToken() {
'use server';
const refreshToken = cookies().get('refresh_token');
if (!refreshToken) {
throw new Error('No refresh token found');
}
// ==> CHECK THE BLACKLIST <==
if (isTokenBlacklisted(refreshToken)) {
console.warn('Attempted to use a blacklisted refresh token.');
// Clear cookies just in case they are still present
// (This is a defensive measure)
const blankAccessTokenCookie = lucia.createBlankSessionCookie();
const blankRefreshTokenCookie = lucia.createBlankSessionCookie();
cookies().set(
blankAccessTokenCookie.name,
blankAccessTokenCookie.value,
{ ...blankAccessTokenCookie.attributes, name: 'access_token' }
);
cookies().set(
blankRefreshTokenCookie.name,
blankRefreshTokenCookie.value,
{ ...blankRefreshTokenCookie.attributes, name: 'refresh_token' }
);
throw new Error('Refresh token has been revoked');
}
// ... (rest of the refresh logic remains the same)
// 1. Find the refresh token in the database
// 2. Validate it
// 3. Create new access and refresh tokens
// 4. Set the new cookies
// ...
// For demonstration, let's assume the rest of the logic is here
// and it successfully generates a new access token.
const newAccessToken = "new-generated-access-token-from-lucia"; // Placeholder
return newAccessToken;
}
Note: The code above assumes you have existing refresh logic. The key addition is the call to isTokenBlacklisted.
Step 3: Integrate into the Client-Side Auth Context
Finally, let's expose a logout function from our AuthContext and call it from our UI.
src/providers/AuthProvider.tsx
// ... imports
import { logoutUser } from '../lib/auth.server';
// ... inside AuthProvider component
const logout = async () => {
try {
await logoutUser(); // Call the server function
setAuth(null); // Clear client-side auth state
// Optionally, navigate the user to the login page
// navigate({ to: '/login' });
} catch (error) {
console.error("Failed to log out", error);
}
};
return (
<AuthContext.Provider value={{ auth, setAuth, logout }}>
{children}
</AuthContext.Provider>
);
src/components/LogoutButton.tsx
import { useAuth } from '../hooks/useAuth';
export function LogoutButton() {
const { logout } = useAuth();
return (
<button onClick={logout}>
Log Out
</button>
);
}
With these changes, when a user clicks the "Log Out" button:
- The
logoutfunction inAuthProvideris called. - It invokes the
logoutUserserver function. - The server blacklists the current refresh token and clears the auth cookies.
- The client then clears its local auth state, removing the user's session from the UI.
- If the now-blacklisted refresh token is ever used again to try and get a new access token, our updated
refreshTokenfunction will reject it.
Conclusion
Congratulations! You have now implemented a complete, secure, and robust JWT session management system. By adding a server-side blacklist, you've closed the loop on the stateless nature of JWTs, ensuring that a logout action is final and secure.
Key Takeaways:
- Statelessness is a double-edged sword: It provides scalability but complicates session revocation.
- Server-side state is necessary for secure logout: A blacklist (or denylist) is the standard pattern for tracking revoked tokens.
- Invalidate the Refresh Token: This is the most critical step, as it prevents the generation of new access tokens.
- A full logout involves three parts:
- Server: Add the token to a blacklist.
- Server: Clear the HTTP-only cookies.
- Client: Clear the local authentication state.
What's Next?
This lesson concludes our deep dive into JWT-based authentication and session management. You've built a system that can handle login, protected routes, automatic token refreshing with concurrent request safety, and secure logout.
In the next module, we will broaden our horizons by exploring a different authentication paradigm: OAuth 2.0. We'll begin by integrating Google Sign-In into our application, learning how to manage third-party authentication flows in an SSR context.