Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Secure Token Invalidation for Logout

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:

  1. Access Token: Short-lived (e.g., 15 minutes). Used to access protected resources.
  2. 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:

  1. Logout Request: The client sends a request to a dedicated /logout endpoint.
  2. 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.
  3. Cookie Clearing: The server clears the HTTP-only cookies containing the access and refresh tokens.
  4. 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 Unauthorized error.

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:

  1. The logout function in AuthProvider is called.
  2. It invokes the logoutUser server function.
  3. The server blacklists the current refresh token and clears the auth cookies.
  4. The client then clears its local auth state, removing the user's session from the UI.
  5. If the now-blacklisted refresh token is ever used again to try and get a new access token, our updated refreshToken function 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:
    1. Server: Add the token to a blacklist.
    2. Server: Clear the HTTP-only cookies.
    3. 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.

Can't find a good explanation? Sign up and we'll make it for you

Sign up