Web Security

The Definitive Guide to Refreshing HttpOnly Tokens

Tired of session timeouts and insecure localStorage? Learn the definitive, secure method for refreshing HttpOnly tokens to build robust and XSS-proof web apps.

D

Daniel Carter

Senior Software Engineer specializing in application security, identity, and access management.

7 min read17 views

If you've ever built a modern web application, you've faced the authentication dilemma. Where do you store your JSON Web Tokens (JWTs)? For years, the go-to answer was `localStorage`. It was simple, it was easy, and as we've painfully learned, it was a security vulnerability waiting to happen. Any Cross-Site Scripting (XSS) attack could hoover up your users' tokens, granting attackers full access to their accounts.

The security-conscious developer knows the answer lies with `HttpOnly` cookies. By storing your access token in a cookie that's inaccessible to JavaScript, you instantly neutralize the most common XSS-based token theft. But this elegant solution introduces a new, tricky problem: if your JavaScript can't see the token, how on earth do you know when it's expired? And more importantly, how do you refresh it without forcing the user to log in again?

This is where many developers get stuck, often reverting to less secure methods out of frustration. But fear not. This guide will walk you through the definitive, secure, and surprisingly seamless pattern for refreshing HttpOnly tokens. We'll demystify the process, turning a complex security challenge into a clear, actionable strategy for building truly robust applications.

What are HttpOnly Tokens and Why Use Them?

An `HttpOnly` cookie is a special type of cookie that can only be accessed by the server. When you set a cookie with the `HttpOnly` flag, you're telling the browser, "Don't let any client-side script, like JavaScript, touch this." Its value is sent automatically with every request to your backend, but it remains completely hidden from your frontend code.

The primary benefit is a massive security win: mitigation of XSS attacks. If an attacker manages to inject a malicious script into your site, that script can't read the cookie to steal the user's session token. The script `document.cookie` will simply not include any `HttpOnly` cookies. When paired with other flags like `Secure` (only send over HTTPS) and `SameSite` (control when the cookie is sent with cross-origin requests), you create a highly secure container for your authentication tokens.

The Classic (but Flawed) Approach: Storing Tokens in localStorage

For context, let's quickly look at the old way. Storing a JWT in `localStorage` meant your JavaScript could read it, add it to an `Authorization: Bearer ` header for every API call, and check its expiration. This was convenient but left the token exposed. Here’s a quick comparison:

FeaturelocalStorageHttpOnly Cookie
JS AccessibilityFully accessible (read/write)Inaccessible
XSS VulnerabilityHigh. A script can easily steal the token.Low. The token is not exposed to scripts.
Automatic SendingNo. Must be manually added to headers.Yes. The browser sends it automatically.
CSRF VulnerabilityLow (not vulnerable by default)Medium. Requires CSRF protection (e.g., `SameSite` attribute).

The table makes it clear: for storing sensitive tokens, `HttpOnly` cookies are the superior choice, provided we can solve the refresh puzzle.

The Secure Foundation: The Access & Refresh Token Pattern

The solution relies on a standard authentication pattern using two types of tokens:

  • Access Token: This is a short-lived token (e.g., 15 minutes) that grants access to protected resources. This is the token we'll store in an `HttpOnly` cookie. Its short lifespan means that even if it were somehow compromised, the window of opportunity for an attacker is very small.
  • Refresh Token: This is a long-lived token (e.g., 7 days) whose only purpose is to get a new access token. It's not used to access data. We will also store this in a separate, even more restricted, `HttpOnly` cookie.
Advertisement

By separating these concerns, we can maintain a seamless user session for days while only ever exposing a token that expires in minutes.

Implementing the Refresh Flow with HttpOnly Cookies

Here’s the core logic. The entire process is designed to be completely invisible to the user. It happens silently in the background between your frontend and backend.

Step 1: The Initial Login

The flow begins when the user logs in with their credentials.

  1. User submits username/password to `POST /api/login`.
  2. The server validates the credentials.
  3. Upon success, the server generates both an access token and a refresh token.
  4. The server sends a `200 OK` response, but crucially, it also includes two `Set-Cookie` headers.

Here's what the server's response headers might look like (pseudo-code):

HTTP/1.1 200 OK
Content-Type: application/json
Set-Cookie: accessToken=...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900
Set-Cookie: refreshToken=...; HttpOnly; Secure; SameSite=Strict; Path=/api/auth/refresh; Max-Age=604800

{"status": "success", "user": {...}}

Notice the different `Path` attributes. The `accessToken` is available for all paths, while the `refreshToken` is only sent when the browser makes a request to `/api/auth/refresh`. This is a key security enhancement, preventing the refresh token from being sent with every single API call.

Step 2: Making Authenticated Requests

Now, when your SPA makes a request to a protected endpoint like `GET /api/user/profile`, the browser automatically attaches the `accessToken` cookie. Your backend middleware simply verifies this token and, if valid, processes the request. The frontend code doesn't need to do anything special.

Step 3: Handling a 401 Unauthorized Error

After 15 minutes, the access token expires. The next time the frontend calls `GET /api/user/profile`, the server's middleware will see the expired token and reject the request with a `401 Unauthorized` status.

This is the trigger for our refresh logic. On the frontend, you should have a global API request handler (like an Axios or Fetch interceptor). This interceptor's job is to catch `401` responses.

Step 4: The Silent Refresh Endpoint

When the interceptor catches a `401`, it doesn't immediately give up. Instead, it does the following:

  1. It silently makes a `POST` request to the special refresh endpoint: `/api/auth/refresh`.
  2. Because of the `Path` we set on the cookie, the browser automatically attaches the long-lived `refreshToken` cookie to this request (and only this request).
  3. The backend receives the request, validates the refresh token, and if it's valid, generates a new access token.
  4. The backend responds with a `200 OK` and a `Set-Cookie` header for the new `accessToken`.
  5. The frontend interceptor receives the `200 OK`, confirming the refresh was successful. It then automatically re-tries the original request that failed (e.g., `GET /api/user/profile`).

This second attempt now includes the new `accessToken` cookie, so the request succeeds. The user sees their profile load, perhaps with a tiny unnoticeable delay. The session has been seamlessly and securely extended.

Putting It All Together: A Step-by-Step Walkthrough

Let's visualize the entire silent refresh flow:

  1. Initial State: User is logged in. Browser holds a valid `accessToken` cookie and a `refreshToken` cookie.
  2. Time Passes: The `accessToken` expires.
  3. API Call Fails: Frontend calls `GET /api/data`. Server sees the expired token and responds with `401 Unauthorized`.
  4. Interceptor Catches 401: The frontend's API interceptor catches the `401` error and pauses the original request.
  5. Refresh Attempt: The interceptor sends a `POST` request to `/api/auth/refresh`. The browser attaches the `refreshToken` cookie.
  6. Server Refreshes: The backend validates the refresh token, generates a new access token, and sends it back in a `Set-Cookie` header with a `200 OK` response.
  7. Retry Original Request: The interceptor sees the successful refresh. It retries the original `GET /api/data` request.
  8. Success! The browser now sends the new `accessToken` cookie. The server validates it, and the request succeeds. The data is returned to the UI, and the user is none the wiser.

Security Considerations & Best Practices

Implementing this pattern is great, but follow these rules to make it truly secure:

  • Refresh Token Rotation: For maximum security, when a refresh token is used, your backend should invalidate it and issue a new one along with the new access token. This helps detect token theft. If an attacker steals and uses a refresh token, the legitimate user's next refresh attempt will fail, which you can use to force a logout and alert the user.
  • CSRF Protection: Cookies are vulnerable to Cross-Site Request Forgery (CSRF). Using `SameSite=Strict` or `SameSite=Lax` on your cookies is your strongest defense. For `Lax`, you may still need additional protection like a double-submit cookie pattern or custom header check for sensitive state-changing operations.
  • Use All The Flags: Always set your cookies with `HttpOnly`, `Secure`, and `SameSite=Strict` (or `Lax`) for a layered defense.
  • Sensible Expiration: Keep access tokens very short-lived (5-15 minutes) and refresh tokens reasonably long-lived (days or weeks), balancing security with user convenience.

Conclusion: Secure and Seamless Authentication

Moving from `localStorage` to an `HttpOnly` cookie-based authentication flow is a significant step up for your application's security. While the refresh logic adds a layer of complexity compared to simply grabbing a token from `localStorage`, the benefits are undeniable. You effectively neutralize XSS-based token theft, providing a far safer environment for your users.

By using the access/refresh token pattern, a dedicated refresh endpoint, and a smart frontend interceptor, you can create a user experience that is both seamless and secure. It's the modern, professional way to handle authentication, and with this guide in hand, you're fully equipped to implement it in your own projects.

You May Also Like