Moving JWT from headers to cookies

One thing that’s been nagging me as we build out our framework is how we’re handling JWTs. Right now, we’re doing what most applications do. The token comes back after authentication and gets sent on every request in the header:

GET http://localhost:8080/someprotectedendpoint
Authorization: Bearer <jwt token>

This works exactly as expected. No real issues there.

But the part that matters isn’t how the token is sent, it’s where it lives on the client.

In most React applications, the easiest place to store that token is localStorage. It’s simple and convenient, but it also means that if you have an XSS vulnerability, that token is exposed. That doesn’t automatically make localStorage wrong, but it does make it a risky place for something as sensitive as an auth token.

That’s what pushed me to revisit cookies. Now, to be clear, this isn’t about cookies being “more secure.” They’re not. They change the tradeoffs.

With headers and localStorage, our main concern is XSS. With cookies, our main concern becomes CSRF, because the browser automatically sends them with requests. We’re not eliminating risk, we’re deciding which one to manage more directly.

For our use case, cookies are the better tradeoff. We keep tokens out of JavaScript, remove the need for manual header management, and simplify the client. But that only works if we implement it correctly.

When setting the cookie, we need to lock it down. HttpOnly prevents JavaScript from accessing it. Secure ensures it’s only sent over HTTPS. Those are non-negotiable. We also want to scope it as tightly as possible. This means setting the most restrictive path we can and avoid broad domain settings.

Then there’s SameSite. This helps reduce CSRF risk, but it’s not a complete solution.

  • Strict gives us the strongest protection but can break legitimate flows
  • Lax is usually a good balance
  • None is required for cross-site setups and must be paired with Secure=true

Even with SameSite set, we still need real CSRF protection. That means using something like a CSRF token or a double-submit cookie pattern. Cookies are sent automatically; that’s the feature and the risk. If we don’t account for that, we’re introducing a vulnerability.

On the backend, the change is straightforward. Instead of returning the JWT in the response body, we set it as a cookie:

String jwt = tokenProvider.generateToken(authentication, JwtTokenProvider.MAX_ACCESS_TOKEN_EXPIRATION);

Cookie cookie = new Cookie("token", jwt);
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setPath("/"); // tighten if possible
cookie.setMaxAge((int)(JwtTokenProvider.MAX_ACCESS_TOKEN_EXPIRATION / 1000)); // seconds

response.addCookie(cookie);
return ResponseEntity.ok().build();

One easy mistake here is forgetting that setMaxAge expects seconds. If our expiration is in milliseconds and you don’t convert it, our cookie lifetime will be wrong.

On the request side, the authentication filter just needs to look for the token in the cookie instead of the header:

Cookie cookie = null;

if (request.getCookies() != null) {
    cookie = Arrays.stream(request.getCookies())
        .filter(c -> c != null && c.getName().equals("token"))
        .findFirst()
        .orElse(null);
}

if (cookie != null) {
    bearerToken = cookie.getValue();
}

From the client’s perspective, things get simpler. If the frontend and backend share the same origin, cookies are sent automatically. If they don’t, we need to enable credentials on requests and configure CORS correctly on the backend. That means allowing credentials and specifying exact origins, no wildcards.

axios.get("http://localhost:8080/someprotectedendpoint", {
    withCredentials: true
});

At this point, it’s also worth calling out what this pattern really is. Once you store a JWT in an HttpOnly cookie and let the browser send it automatically, we’re effectively closer to a session-based model than a traditional stateless API token approach. That’s not a problem; it’s just important to be honest about the architecture you’re choosing.

Because of that, we should also think about token lifecycle. Keep expirations reasonably short, and make sure logout actually clears the cookie. If the cookie is the authentication mechanism, it needs to be actively managed.

If we keep evolving this, a more complete setup is to use short-lived access tokens (kept in memory) and store only a refresh token in an HttpOnly cookie. That limits exposure even further while keeping the same general approach.

At the end of the day, this isn’t about cookies vs headers. It’s about understanding where our token lives, who can access it, and what happens when something goes wrong.

For us, cookies are the better tradeoff right now, but only because we’re being deliberate about how we implement them.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.