OWASP A07: Authentication Failures (2) — Cookie Flags, JWT Validation and CSRF
We look at how identity is carried on every request after login. We set cookie flags and the __Host- prefix explicitly, explain the session fixation risk, validate issuer, audience, lifetime, signature and algorithm in JwtBearer, measure the effect of ClockSkew on an expired token second by second, and show how antiforgery and SameSite work together against CSRF.
Version note: This post follows the OWASP Top 10:2025; the code was tested with ASP.NET Core 10 (.NET 10).
Guess First
You logged in to the Bookstore API and the server gave you a cookie. Can JavaScript read that cookie? If a form on some other site's page sends a request from your browser to the Bookstore API, does the cookie go along with it? And on the JWT side: if you send a request with a token that expired five seconds ago, what does the API say?
The answer to the last question is not what most people expect. In the previous post we verified who gets through the door; in this one we look at how that identity is carried on the requests that follow.
Identity Travels With Every Request
HTTP is stateless; the server does not remember the previous request. When a login succeeds, the server hands the client a proof, and the client sends that proof back on every following request. The proof is either a cookie (the browser attaches it automatically) or a token (the client code puts it in the Authorization header itself). The two paths carry different risks. Because the browser attaches the cookie automatically, the main threat there is CSRF; because JavaScript holds the token, the main threat there is theft through XSS.
One of the CWEs under A07:2025 Authentication Failures is directly about this: session fixation (CWE-384). If an attacker can decide the session identifier the victim will use before login, and the server keeps using the same identifier after login, the attacker owns the victim's session through it. The defense is to issue a new identifier at login.
In ASP.NET Core cookie authentication there is no identifier before login; SignInAsync creates a new ticket at that moment, encrypted and signed with Data Protection. The risk is in code that keeps login state in the ISession that comes with AddSession: ISession has no method that renews the identifier at login. Leave login state to the authentication cookie and use the session for non-sensitive data like a cart.
All examples run in the lab on your own machine; only try these techniques on systems you own or have written permission to test.
Cookie Flags
First let's see what cookie authentication produces with nothing configured. With the lab listening on HTTP and HTTPS at the same time, logging in on both sides gives:
$ curl -i -X POST http://localhost:5152/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"Dev-only-Bookstore-2026"}'
HTTP/1.1 200 OK
...
Set-Cookie: .AspNetCore.Cookies=CfDJ8...; path=/; samesite=lax; httponly
$ curl -i -X POST https://localhost:5151/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"Dev-only-Bookstore-2026"}'
HTTP/2 200
...
set-cookie: .AspNetCore.Cookies=CfDJ8...; path=/; secure; samesite=lax; httponly
The defaults are not bad: HttpOnly is on, SameSite=Lax. But the Secure flag depends on the request. The default SecurePolicy is SameAsRequest; if the request came over HTTP, the cookie goes out without Secure. In production, if a reverse proxy terminates TLS and forwarded headers are not set up correctly, the app thinks every request is HTTP and every cookie leaves without the flag. That is why I write the flags explicitly:
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
// __Host- prefix: makes the browser enforce Secure, Path=/ and no Domain
options.Cookie.Name = "__Host-bookstore";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
})
Each flag has one job. HttpOnly hides the cookie from document.cookie; even if the page has an XSS hole, a script cannot read the cookie and send it away. Secure makes sure the cookie is sent only over HTTPS. SameSite decides whether the cookie is sent on requests started from another site; we will come back to it in the CSRF section. The __Host- prefix is a browser rule too: the browser accepts a cookie with this prefix only if it arrives with Secure, Path=/ and no Domain, so a subdomain cannot overwrite it. The default lifetime is 14 days; 8 hours is enough for a bookstore.
$ curl -i -X POST https://localhost:5151/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"Dev-only-Bookstore-2026"}'
HTTP/2 200
...
set-cookie: __Host-bookstore=CfDJ8...; path=/; secure; samesite=lax; httponly
{"message":"Welcome"}
$ curl -i https://localhost:5151/me
HTTP/2 401
...
location: https://localhost:5151/Account/Login?ReturnUrl=%2Fme
content-length: 0
The cookie has no expires, so it is a session cookie that goes away when the browser closes; the 8-hour limit lives in the ticket inside the cookie. The second request shows a .NET 10 change: for an API endpoint that returns JSON, cookie authentication no longer redirects to a login page but returns 401. The location header is still written, but the status code is 401, not 302.
On logout, SignOutAsync only deletes the cookie in the browser; a copied cookie stays valid until the ticket expires. For server-side revocation, look at the SessionStore (ITicketStore) option or Identity's security stamp validation.
JWT: Reading Is Not Validating
A JWT has three parts: header, payload and signature. The first two are only Base64Url encoded; they are not encrypted, anyone can read them. Trust comes from the signature. Most JWT mistakes come from missing that distinction.
The first is reading the token without validating it. Code that decodes the payload and trusts the sub or role value in it is trusting text anyone could have written. The second is letting the token choose the algorithm. The alg field in the header is a statement by whoever produced the token; the validating side must decide which algorithm it accepts. Libraries that left this decision to the token have in the past made mistakes like accepting unsigned tokens. The third is not checking issuer and audience; then a token issued for another service also works against your API. The fourth is a long lifetime: a JWT is hard to revoke on the server, so a stolen one-day access token is valid for a day.
In ASP.NET Core, AddJwtBearer does the validation. The lab configuration looks like this:
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwt.Issuer,
ValidateAudience = true,
ValidAudience = jwt.Audience,
ValidateLifetime = true,
RequireExpirationTime = true,
// The default is 5 minutes; far too much for a short-lived token
ClockSkew = TimeSpan.FromSeconds(30),
RequireSignedTokens = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = signingKey,
// The server picks the algorithm, not the token
ValidAlgorithms = [SecurityAlgorithms.HmacSha256]
};
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy("api", policy => policy
.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser());
Some of these values are already the default. Printing an empty TokenValidationParameters gives:
ValidateIssuer=True ValidateAudience=True ValidateLifetime=True RequireExpirationTime=True RequireSignedTokens=True ValidateIssuerSigningKey=False ClockSkew=00:05:00
I still write them out, so the reader does not have to guess what is validated. ValidAlgorithms is empty by default, which means any algorithm that fits the key is accepted; narrow the list yourself. The signing key's name starts with dev-only-...; in a real app the key comes from user-secrets, an environment variable or a vault, as described in the A04 post. The /api/orders endpoint is tied to this policy through RequireAuthorization("api"):
$ curl -i https://localhost:5151/api/orders
HTTP/2 401
www-authenticate: Bearer
content-length: 0
$ curl -i https://localhost:5151/api/orders -H "Authorization: Bearer not-a-jwt"
HTTP/2 401
www-authenticate: Bearer error="invalid_token"
content-length: 0
$ curl -i -b jar.txt https://localhost:5151/api/orders
HTTP/2 401
www-authenticate: Bearer
content-length: 0
$ curl -i https://localhost:5151/api/orders -H "Authorization: Bearer $TOKEN"
HTTP/2 200
content-type: application/json; charset=utf-8
{"user":"ada@example.com","orders":[1001,1002]}
No token gives 401, a broken token gives 401 with invalid_token. The third request is interesting: the login cookie is valid, but the policy accepts only the Bearer scheme, so the cookie does nothing for this endpoint. Which endpoint accepts which proof of identity should be defined this explicitly. The fourth uses a token taken from the /token endpoint with the correct password.
Expired Tokens and ClockSkew
I started the lab with the Jwt__LifetimeSeconds=5 environment variable; the token lives for five seconds. I take a token and send the same request right away, after 20 seconds and after 37 seconds:
Fri Nov 6 19:14:29 UTC 2026
200
after 20s 200
Fri Nov 6 19:15:06 UTC 2026
HTTP/2 401
www-authenticate: Bearer error="invalid_token", error_description="The token expired at '11/06/2026 19:14:34'"
content-length: 0
The token expired at 19:14:34 but was still accepted 20 seconds later. The reason is ClockSkew: a margin added to expiry to tolerate clock differences between servers. We lowered it to 30 seconds, and the token was rejected only after that margin ran out as well. The default is 5 minutes; a 5-minute access token actually lives for 10. That was the answer to the question in Guess First.
Where should the token live? OWASP's HTML5 Security Cheat Sheet says not to keep session identifiers in localStorage; every script on the page can reach that data and a single XSS takes all of it. For a SPA the safer path is the backend-for-frontend (BFF) approach, which never hands the token to the browser and uses an HttpOnly, Secure, SameSite cookie. If you must keep the token on the client, keep it in memory and short-lived.
CSRF: No Heading of Its Own, Still Here
CSRF no longer has its own heading in the Top 10; in the 2025 list it sits under A01 Broken Access Control as CWE-352. Its mechanism comes from the nature of cookies: the browser attaches a site's cookies to a request going to that site, no matter which page started the request. If a form on another site is posted from your browser to the Bookstore API and your cookie is attached, the request looks to the server as if it came from you.
The first line of defense is SameSite. A Lax cookie is not sent on POSTs and subresource requests started from another site, but it is sent when the user clicks a link and arrives at the site with a top-level GET. So a GET request must never change state. Strict does not send the cookie on that GET either; it is safe, but a user coming from a link in an email looks logged out. Do not rely on browser defaults: treating a cookie without SameSite as Lax is the behavior in Chrome, only behind a setting in Firefox, and absent in Safari. Also, SameSite looks at the "site", not the "origin"; shop.example.com and blog.example.com are the same site. That is why a second line is needed too: the antiforgery token.
builder.Services.AddAntiforgery(options =>
{
options.Cookie.Name = "__Host-bookstore-af";
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
WebApplication app = builder.Build();
// AddAntiforgery only registers services; this line turns validation on
app.UseAntiforgery();
...
app.MapGet("/reviews/new", (HttpContext http, IAntiforgery antiforgery) =>
{
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(http);
string html = $"""
<form method="post" action="/reviews">
<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}" />
<input name="bookId" /> <textarea name="text"></textarea>
<button>Send</button>
</form>
""";
return Results.Content(html, "text/html");
}).RequireAuthorization();
app.MapPost("/reviews", ([FromForm] ReviewForm form, ClaimsPrincipal user) =>
Results.Ok(new { saved = true, by = user.Identity!.Name, form.BookId }))
.RequireAuthorization();
Antiforgery uses a pair: one in a cookie, one in the form's hidden field. They must match each other and the logged-in user. Another site can submit the form from your browser, but because of the same-origin policy it cannot read the value of the hidden field on your page. Since .NET 8, minimal API endpoints that bind [FromForm] are validated by default; the condition is that UseAntiforgery() is in the pipeline. The results:
$ curl -i -b jar.txt -c jar.txt https://localhost:5151/reviews/new
HTTP/2 200
content-type: text/html
...
set-cookie: __Host-bookstore-af=CfDJ8...; path=/; secure; samesite=strict; httponly
x-frame-options: SAMEORIGIN
<form method="post" action="/reviews">
<input type="hidden" name="__RequestVerificationToken" value="CfDJ8..." />
...
$ curl -i -b jar.txt -X POST https://localhost:5151/reviews -d "bookId=1&text=Great"
HTTP/2 400
content-length: 0
$ curl -i -b jar.txt -X POST https://localhost:5151/reviews --data-urlencode "__RequestVerificationToken=$TOKEN" -d "bookId=1&text=Great"
HTTP/2 200
content-type: application/json; charset=utf-8
{"saved":true,"by":"ada@example.com","bookId":1}
The post without a token was rejected with 400 and the endpoint code never ran. The antiforgery cookie came with samesite=strict and httponly on its own, and an X-Frame-Options: SAMEORIGIN header was added on top, so embedding the form in another site's iframe is blocked as well.
Endpoints that take a JSON body are not validated automatically. If you have a cookie-based JSON API, expect the token in a header: Angular's HttpClient reads the XSRF-TOKEN cookie and puts it in the X-XSRF-TOKEN header, and on the server options.HeaderName = "X-XSRF-TOKEN" plus IAntiforgery.ValidateRequestAsync complete the pairing. Endpoints that use Bearer tokens are not affected by CSRF, because the browser does not add the Authorization header on its own; consider .DisableAntiforgery() only for them. The .NET 11 documentation on learn.microsoft.com describes an automatic CSRF middleware that checks the Sec-Fetch-Site and Origin headers without tokens; in .NET 10, UseAntiforgery is still your job.
Common Pitfalls
The first pitfall is writing SecurePolicy = Always and testing the app over HTTP. The first time I ran the lab I used HTTP, and the first request for the form returned 500. The reason in the log was clear: The antiforgery system has the configuration value AntiforgeryOptions.Cookie.SecurePolicy = Always, but the current request is not an SSL request. The fix is not to loosen the setting but to use the HTTPS dev certificate in development too; that is why the lab runs over HTTPS.
The second is a short signing key. With the key set to dev-only-short-key, token creation blew up with 500: IDX10720: Unable to create KeyedHashAlgorithm for algorithm 'HS256', the key size must be greater than: '256' bits, key has '144' bits. The library protects you here; generate a random key of at least 32 bytes for HS256.
The third is forgetting ClockSkew and believing you shortened the token lifetime. The fourth is relying on CORS as CSRF protection. CORS decides whether another site can read the response; it does not stop the request from being sent. The fifth is writing a GET endpoint that changes state; SameSite=Lax does not protect that request. The sixth is adding .DisableAntiforgery() to a cookie-based endpoint "because it was failing".
Checklist
Does your authentication cookie go out with HttpOnly, Secure (SecurePolicy.Always) and an explicitly written SameSite? Do you use the __Host- prefix where possible? Have the cookie and ticket lifetimes been shortened to what the app needs? Do you keep login state in the authentication cookie rather than in ISession?
Does JWT validation explicitly check issuer, audience, lifetime and signature, is ValidAlgorithms narrowed, and is ClockSkew a deliberate value? Is the signing key at least 256 bits and outside the code? Are access tokens short-lived, and are they sitting in localStorage in the browser? Is every endpoint that takes a form protected by antiforgery, and is UseAntiforgery() in the pipeline? Do cookie-based JSON endpoints have header-based validation?
A Small Experiment
★☆☆ Delete the ClockSkew line and leave the token lifetime at 5 seconds. Measure how many seconds later the token is rejected and compare with the default value.
★★☆ Add a second user to the lab. Take a token from /reviews/new with Ada's session, then try sending that token with the second user's cookies. Confirm with the response code that the token is bound to the user.
★★★ Write a copy of the /reviews endpoint that accepts JSON. Configure antiforgery to validate it through the X-XSRF-TOKEN header, add an endpoint that returns the XSRF-TOKEN cookie and show with curl that a request without the header is rejected.
We have seen how to carry identity safely. In the next post we move to A08 Software or Data Integrity Failures: the integrity of data and code from sources you do not trust, why insecure deserialization is dangerous, the end of BinaryFormatter in .NET 9, and closing mass assignment with DTOs.