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

Server-Side Token Management with HTTP-Only Cookies

Hello! Let's dive into our next lesson.

In our previous session, we laid the groundwork for secure authentication by implementing cookie-based session management. We established that HttpOnly cookies are the right tool for the job in an SSR context and got a high-level overview of the access and refresh token strategy.

Today, we will build on that foundation to fully implement this robust authentication pattern. This lesson is designed to meet the following learning outcome:

Securely manage authentication tokens (access and refresh) using server-side logic and HTTP-only cookies.

We'll move from the "what" to the "how," detailing the server-side logic for issuing, validating, and refreshing tokens. This pattern is the industry standard for creating secure, persistent user sessions in modern web applications.

1. The Token Handler Pattern: A Formal Architecture

The strategy we are implementing is a well-known architectural pattern called the Token Handler Pattern, often implemented as part of a Backend for Frontend (BFF). In this model, your web application (the SPA) does not handle tokens directly. Instead, it communicates with a dedicated backend component (our Tanstack Start server) that manages the entire token lifecycle. This backend component then issues a secure, HttpOnly cookie to the browser to maintain the session.

This architecture provides significant security benefits by keeping sensitive tokens out of the browser environment, where they could be exposed to XSS attacks.

Token Handler Design Overview

To understand the security principles and benefits of this architecture, let's review a high-level overview of the Token Handler Pattern from Curity.io.

Please read the sections 'Browser Security' and 'Token Handler Pattern'. Focus on the core idea: keeping all tokens out of the browser and using a BFF to issue secure HTTP-Only, SameSite=strict cookies.

2. The Access & Refresh Token Strategy Revisited

As we discussed, the core of this pattern involves two distinct types of JSON Web Tokens (JWTs):

  • Access Token: A token with a short lifespan (e.g., 15 minutes). It is sent with every API request to authorize the user. Its short life limits the potential damage if it is ever compromised.
  • Refresh Token: A token with a long lifespan (e.g., 7-30 days). Its only purpose is to obtain a new access token when the old one expires. It is stored in the most secure way possible and used infrequently.

The following video provides an excellent visual walkthrough of how these two tokens work together to create a seamless and secure user session.

Authentication in React with JWTs, Access & Refresh Tokens (Complete Tutorial)

This video from Cosden Solutions clearly illustrates the complete authentication flow, from initial login to automatic token refresh.

Please watch from the 'Introduction to JWTs' through the end of the 'Token Expiration and Refresh Mechanism' section. Pay close attention to: The distinct roles and lifespans of the access and refresh tokens. The flow: how the server stores the refresh token in an HttpOnly cookie and sends the access token back. The refresh mechanism: how an expired access token triggers a request to get a new one using the refresh token.

Adapting the Pattern for SSR

The video you just watched describes a common pattern for SPAs where the access token is stored in the client's memory (e.g., React state). In our Tanstack Start SSR application, we can achieve even better security and simplicity.

  • Refresh Token: As described, this token belongs in our encrypted, HttpOnly session cookie, managed by useSession. It never leaves the server-client HTTP exchange and is inaccessible to JavaScript.
  • Access Token: We will also store the access token inside the same encrypted session cookie. This is a key advantage of our SSR architecture. By keeping the access token on the server side within the session, it's immediately available to our route loaders for server-side data fetching, and it remains shielded from the client-side environment.

3. Server-Side Implementation: The Core Logic

Now, let's look at the code. The entire token management lifecycle—issuing, refreshing, and validating—is handled by server-side logic, which we'll implement using Tanstack's createServerFn.

The following resource provides excellent, practical Express.js examples. The logic is directly translatable to server functions in Tanstack Start.

Ultimate Guide to Securing JWT Authentication ...

This guide from Wisp Blog provides clear, server-side code examples for a complete JWT authentication flow. We will analyze the key endpoints.

Please study the code snippets in the sections 'Server-Side Implementation' and 'Token Refresh Implementation'. Login Endpoint (/api/login): In the login endpoint, notice how after validating credentials, it creates both an accessToken and a refreshToken and sets them as separate HttpOnly cookies. In our case, we would place both inside a single encrypted session object. Token Refresh Endpoint (/api/refresh): Observe how the refresh endpoint reads the refreshToken from the cookies, verifies it, and then issues a new accessToken.

Let's map this logic to our Tanstack Start application.

Step 1: The Login Function

Your login server function would look conceptually like this:

// src/server/auth.ts
import { createServerFn } from '@tanstack/react-start/server'
import { getAppSession } from '~/lib/session'
// Assume you have functions to generate tokens
import { generateAccessToken, generateRefreshToken } from '~/lib/jwt'

export const loginFn = createServerFn('POST', async (credentials: LoginCredentials) => {
  // 1. Validate user credentials against your database
  const user = await validateUser(credentials);
  if (!user) {
    throw new Error('Invalid credentials');
  }

  // 2. Generate both tokens
  const accessToken = generateAccessToken({ userId: user.id, role: user.role });
  const refreshToken = generateRefreshToken({ userId: user.id });

  // 3. Get the session utility
  const session = getAppSession();

  // 4. Update the session with token data
  await session.update({
    status: 'authenticated',
    user: { id: user.id, email: user.email },
    accessToken: accessToken.token,
    accessTokenExpiresAt: accessToken.expiresAt, // Store expiration timestamp
    refreshToken: refreshToken.token,
  });

  return { user: { id: user.id, email: user.email } };
});

When this function runs, Tanstack Start encrypts the entire session object and sends it to the browser in a single, secure, HttpOnly cookie.

Step 2: The Token Refresh Function

You need a dedicated server function for refreshing the token.

// src/server/auth.ts
export const refreshTokenFn = createServerFn('POST', async () => {
  const session = getAppSession();
  const sessionData = await session.get();

  // 1. Check for an existing session and refresh token
  if (sessionData?.status !== 'authenticated' || !sessionData.refreshToken) {
    throw new Error('No refresh token available.');
  }

  // 2. Validate the refresh token
  const payload = await validateRefreshToken(sessionData.refreshToken);
  if (!payload) {
    await session.clear(); // Invalid refresh token, clear the session
    throw new Error('Invalid refresh token.');
  }

  // 3. Generate a new access token
  const newAccessToken = generateAccessToken({ userId: payload.userId, role: payload.role });

  // 4. Update the session with the new access token
  await session.update({
    ...sessionData,
    accessToken: newAccessToken.token,
    accessTokenExpiresAt: newAccessToken.expiresAt,
  });

  // Return the new token so the client can retry a failed request if needed
  return { accessToken: newAccessToken.token };
});

Step 3: Using Tokens in Route Loaders

In your route loaders, you can now access the session, check the access token, and even refresh it transparently before rendering the page. This provides a superior user experience compared to client-side refresh flows.

// routes/dashboard.tsx
import { createFileRoute } from '@tanstack/react-router'
import { getAppSession } from '~/lib/session'
import { refreshTokenFn } from '~/server/auth'

export const Route = createFileRoute('/dashboard')({
  loader: async () => {
    const session = getAppSession();
    let sessionData = await session.get();

    if (sessionData?.status !== 'authenticated') {
      // Redirect to login, not authenticated
      throw redirect({ to: '/login' });
    }

    // Proactive refresh: check if token is expiring soon
    const isTokenExpiring = sessionData.accessTokenExpiresAt <= Date.now() + 60 * 1000; // 1 min buffer

    if (isTokenExpiring) {
      try {
        // The refresh function will automatically update the cookie
        await refreshTokenFn();
        // Re-fetch the session to get the new token data for this render
        sessionData = await session.get();
      } catch (error) {
        // Refresh failed, treat as logged out
        await session.clear();
        throw redirect({ to: '/login' });
      }
    }
    
    // Now, use the valid access token to fetch data
    const data = await fetchProtectedData(sessionData.accessToken);
    return { data, user: sessionData.user };
  }
})

4. Essential Security Best Practices

Implementing the flow correctly is only half the battle. Securing it is just as important.

Cross-Site Request Forgery (CSRF) Protection

HttpOnly cookies are automatically sent by the browser with any request to your domain, which makes them susceptible to CSRF attacks. The primary defense is the SameSite cookie attribute.

  • SameSite=strict: The browser will only send the cookie if the request originates from your own site. This is the most secure option and effectively stops CSRF.
  • SameSite=lax: A reasonable default. The cookie is sent on same-site requests and with top-level navigations (e.g., clicking a link from another site to yours), but not with cross-origin POST requests.

Tanstack Start's useSession defaults to lax, which is a strong baseline. You should configure this in your session utility:

// src/lib/session.ts
export function getAppSession() {
  return useSession({
    // ... other config
    cookie: {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict', // 'strict' is best if your app flow allows for it
    },
  });
}

For an even deeper layer of security, especially for state-changing operations (POST, PUT, DELETE), you can implement the Double Submit Cookie pattern using CSRF tokens.

Ultimate Guide to Securing JWT Authentication ...

Let's revisit the Wisp Blog guide to see how CSRF is mitigated.

Please read the sections 'Mitigating CSRF Vulnerabilities' and 'Common Pitfalls and Best Practices'. Focus on the roles of the SameSite cookie attribute and the secure logout implementation.

Secure Logout

A secure logout must invalidate the session on the server.

  1. Clear the Session Cookie: Your logout server function must call session.clear(). This instructs the browser to delete the cookie.
  2. Invalidate the Refresh Token (Optional but Recommended): If your system design allows, you should also invalidate the refresh token itself in your database. This prevents it from being used to generate new access tokens, even if it were somehow compromised before the cookie was cleared.

Conclusion

You have now learned the complete, secure pattern for managing authentication tokens in an SSR application. By centralizing this logic on the server and leveraging the security features of HttpOnly cookies, you create a system that is both robust and provides a seamless user experience.

Key Takeaways:

  • The Server is the Custodian: In the Token Handler (BFF) pattern, the server is solely responsible for the entire token lifecycle.
  • HttpOnly Cookies are for Storage: The encrypted session cookie is the secure container for both the long-lived refresh token and the short-lived access token.
  • Server Functions are for Logic: Use createServerFn to implement login, logout, and refreshToken endpoints.
  • Proactive Refresh in Loaders: Route loaders are the perfect place to transparently refresh an expiring access token before rendering a page.
  • Security is Paramount: Always use SameSite cookie attributes to prevent CSRF and ensure your logout process fully invalidates the session on the server.

Next Up:

We have perfected our server-side authentication state. The server knows who the user is at all times. But how do we make this information available to our client-side React components to conditionally render the UI (e.g., show a "Profile" link instead of "Login")? In our next lesson, we will create a React context provider to bridge this authentication state from the server to the client.

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

Sign up