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.

AuthController.cs
[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.

API/Dependencies.cs
.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.

double-submit CSRF token — client sends it back in a header
// 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:

fetch/HttpClient — no manual header needed
// 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.