HttpOnly Cookie Authentication
The JWT Authentication page stores the token in sessionStorage and attaches it manually to every request — simple, and fine for many apps, but readable by any JavaScript running on the page (including an injected script from an XSS bug). An HttpOnly cookie is invisible to JavaScript entirely; the browser attaches it automatically and the server is the only thing that can read it.
Issuing the cookie on login
Same TokenService as the JWT page — the token itself doesn't change shape. What changes is where it's put: instead of returning it in the response body for the client to store, it's written directly into a cookie on the response.
[HttpPost("login")]
public async Task<IActionResult> LoginAsync(LoginRequest request)
{
var token = await _authService.LoginAsync(request); // throws AuthenticationException on failure
Response.Cookies.Append("auth_token", token.JWT, new CookieOptions
{
HttpOnly = true, // invisible to document.cookie / JS
Secure = true, // HTTPS only — never send it over plain HTTP
SameSite = SameSiteMode.Strict, // browser withholds it on cross-site requests
Expires = token.ExpiresOn,
Path = "/"
});
return Ok(ApiResponse<object>.Ok()); // no token in the body — nothing for JS to read
}
[HttpPost("logout")]
public IActionResult Logout()
{
Response.Cookies.Delete("auth_token");
return Ok(ApiResponse<object>.Ok());
}
Reading the cookie instead of the Authorization header
JWT bearer auth expects the token in an Authorization: Bearer ... header by default. OnMessageReceived lets you pull it from the cookie instead, so the rest of the JWT bearer pipeline — validation, claims, [Authorize] — works unchanged.
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters { /* same as before */ };
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
if (context.Request.Cookies.TryGetValue("auth_token", out var token))
context.Token = token;
return Task.CompletedTask;
}
};
});
CSRF: the trade-off that comes with cookies
A header-based token (like the JWT-in-storage approach) is naturally immune to CSRF — a malicious site can't read your sessionStorage to forge the header. A cookie is sent automatically by the browser on any request to your domain, including ones triggered by a malicious page, so you need an explicit CSRF defense. SameSite=Strict handles most of it for a same-site app; for cross-site scenarios (a separate frontend domain calling the API), add a double-submit token.
// on login, also set a *readable* (non-HttpOnly) token the frontend echoes back
Response.Cookies.Append("csrf_token", csrfToken, new CookieOptions
{
HttpOnly = false, // JS needs to read this one, to put it in a header
Secure = true,
SameSite = SameSiteMode.Strict
});
// on every mutating request, require the header to match the cookie
[HttpPost]
[Authorize]
public IActionResult CreateResource([FromBody] CreateResourceRequest request)
{
var cookieToken = Request.Cookies["csrf_token"];
var headerToken = Request.Headers["X-CSRF-Token"].ToString();
if (string.IsNullOrEmpty(cookieToken) || cookieToken != headerToken)
return Forbid();
// ... proceed
}
Client side: nothing to store, nothing to attach
This is the appeal — compare to the whole JWT Auth State page's worth of session storage, claims parsing, and a DelegatingHandler. With cookies, the browser does that work:
// the browser attaches the HttpOnly cookie automatically — nothing to add here
fetch("/api/resource", {
method: "POST",
credentials: "include", // required for cross-origin requests to send cookies
headers: { "X-CSRF-Token": csrfTokenFromCookie },
body: JSON.stringify(data)
});
Pick cookies when the frontend and API share a domain (or close subdomains) and you want the smallest possible XSS blast radius. Pick JWT-in-storage (the JWT Authentication page) when the API serves multiple, unrelated frontend origins, or a native/CLI client that has no cookie jar at all.