Minimalist Approach to ASP.NET Core Forms Authentication
HTTP Cookies
HTTP is stateless by default, so there is no concept of an authenticated user that persists beyond a single request. To overcome this limitation in a web application, you can use cookies. A cookie can be set in response to an HTTP request through a server header and will automatically be resent by the client for the specified scope (e.g., domain).
Here is an example of an authentication cookie being set and then sent by the client in a later request:
- Unauthenticated Request
POST /login?ReturnUrl=%2F HTTP/1.1
Host: localhost:5219
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: de,en-US;q=0.7,en;q=0.3
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: application/x-www-form-urlencoded
Content-Length: 9
Origin: http://localhost:5219
Sec-GPC: 1
Connection: keep-alive
Referer: http://localhost:5219/login?ReturnUrl=%2F
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Priority: u=0, i
- Response with Cookie
HTTP/1.1 302 Found
Content-Length: 0
Date: Sat, 15 Nov 2025 06:45:58 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Location: /
Pragma: no-cache
Set-Cookie: AuthCookie=CfDJ8NwSkOMQD2BJjkMwqvu-496cj59mmYimzGi9ent72QIxSJacXxDdSdy7ke2YXr7u5JJ1oQ-NWaQziRJ76mQgKiUHrZaUj91EVaTePSTFV-4udivI8nb37ft18pKJDaOfJSuZ1MxUS8ohC6CmyNKCXpU_J2zz_syAzCYvNudh32hgUDpSXBE6yW1Se-noZSWfT8rzkS2TgJdWa5IgciILthAqkGIftoEhQ7YWOTZj9V8n80vrSLpLDJLK_hJlxnvJfNP1_UCsWT8euonLWY_sooq6ug48Vn3ztcLVluz6Cwu-MhZpdjK-3GYNdD3olJke1mwp28PaRioUceMaXUNN3f-omxXgXAmyTakEeNQ1ua4xVwFdRgQHhcPHs18-BLVMjE8OG_ANu3DNWVOaT_JMnL0; path=/; samesite=lax
- Authenticated Request
GET / HTTP/1.1
Host: localhost:5219
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: de,en-US;q=0.7,en;q=0.3
Accept-Encoding: gzip, deflate, br, zstd
Referer: http://localhost:5219/login?ReturnUrl=%2F
Sec-GPC: 1
Connection: keep-alive
Cookie: AuthCookie=CfDJ8NwSkOMQD2BJjkMwqvu-496cj59mmYimzGi9ent72QIxSJacXxDdSdy7ke2YXr7u5JJ1oQ-NWaQziRJ76mQgKiUHrZaUj91EVaTePSTFV-4udivI8nb37ft18pKJDaOfJSuZ1MxUS8ohC6CmyNKCXpU_J2zz_syAzCYvNudh32hgUDpSXBE6yW1Se-noZSWfT8rzkS2TgJdWa5IgciILthAqkGIftoEhQ7YWOTZj9V8n80vrSLpLDJLK_hJlxnvJfNP1_UCsWT8euonLWY_sooq6ug48Vn3ztcLVluz6Cwu-MhZpdjK-3GYNdD3olJke1mwp28PaRioUceMaXUNN3f-omxXgXAmyTakEeNQ1ua4xVwFdRgQHhcPHs18-BLVMjE8OG_ANu3DNWVOaT_JMnL0
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Priority: u=0, i
This simple mechanism allows web applications to maintain a session between requests without requiring the client to do more than follow the HTTP specification and present cookies for subsequent requests.
ASP.NET Core Authentication
ASP.NET Core authentication is a flexible system that sets up a user (ClaimsPrincipal) before passing control to user code. For forms authentication, this means that incoming HTTP requests are checked for the auth cookie, and a ClaimsPrincipal is created from it.
Enabling this mechanism (without any user management) is quite straightforward:
// register services in DI
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie();
// add middleware to the request pipeline
app.UseAuthentication();
app.UseAuthorization();
The middleware ensures that the authenticated user is available on the HttpContext. When you're inside a Controller, you can access this through the User property of the base class.
To set the authentication cookie (for example, inside a login form), you first create a ClaimsPrincipal and then use it to derive the cookie.
public async Task<IActionResult> Post(string user)
{
var claims = new[]
{
new Claim(ClaimTypes.Name, user),
new Claim(ClaimTypes.Role,$"{user}s" )
};
var identity = new ClaimsIdentity(claims,
CookieAuthenticationDefaults.AuthenticationScheme);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity));
return Redirect("/");
}
Accessing the Principal Outside the Controller
If you want to access the Principal outside the controller, such as to enforce business rules based on role membership, you can use IHttpContextAccessor.
// registers IHttpContextAccessor on the DI container
services.AddHttpContextAccessor();
The drawback of directly injecting IHttpContextAccessor into your services is that you are tied to ASP.NET Core where you don’t have to. A simple way to resolve this is to introduce an adapter.
class SecurityContext
{
private readonly IHttpContextAccessor _httpContextAccessor;
public UserContext(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public ClaimsPrincipal? User => _httpContexdtAccessor.HttpContext?.User;
}
With this adapter, your services can also work outside of a web context, such as in messaging or scheduled jobs.
Types of Authentication Cookies
You can generally divide authentication cookies (or authentication tokens) into two types: opaque and self-contained. The example I showed above is the standard ASP.NET Core cookie, which is self-contained.
Self-contained
Self-contained means the cookie contains all the information needed to recreate the Principal. In the standard ASP.NET Core implementation, the cookie payload is encrypted.
00000000 09 f0 c9 f0 dc 12 90 e3 10 0f 60 49 8e 43 30 aa |.ðÉðÜ..ã..`I.C0ª|
00000010 fb b8 f7 a7 23 e7 d9 a6 62 29 b3 1a 2f 5e 9e de |û¸÷§#çÙ¦b)³./^.Þ|
00000020 f6 40 8c 52 25 a7 17 c4 37 52 77 2e e4 7b 66 17 |ö@.R%§.Ä7Rw.ä{f.|
00000030 af bb b9 24 9d 68 40 d5 9a 43 38 91 27 be a6 42 |¯»¹$.h@Õ.C8.'¾¦B|
00000040 02 a2 50 7a d9 69 48 fd d4 45 5a 4d e3 d2 4c 55 |.¢PzÙiHýÔEZMãÒLU|
00000050 78 b9 d8 af 23 c9 db df b7 ed d7 ca 4a 24 36 8e |x¹Ø¯#ÉÛß·í×ÊJ$6.|
00000060 7c 94 ae 67 53 31 51 2f 28 84 2e 82 9b 23 4a 09 ||.®gS1Q/(....#J.|
00000070 7a 54 27 6c f3 b3 20 33 09 8b cd b9 d8 77 da 18 |zT'ló³ 3..͹ØwÚ.|
00000080 14 0e 94 97 04 4e b2 5b 54 9e 9e 86 52 59 f4 fc |.....N²[T...RYôü|
00000090 af 39 12 d9 38 09 75 66 b9 22 07 22 20 bb 61 02 |¯9.Ù8.uf¹"." »a.|
000000a0 a9 06 21 fb 68 12 14 3b 61 63 93 66 3f 55 f2 7f |©.!ûh..;ac.f?Uò.|
000000b0 34 be b4 8b a4 b0 c9 2c a8 49 97 19 ef 25 f3 4f |4¾´.¤°É,¨I..ï%óO|
000000c0 d5 40 ac 59 3f 1e ba 89 cb 59 8b 28 a2 ae ae 83 |Õ@¬Y?.º.ËY.(¢®®.|
000000d0 8f 15 9f 7c ed 70 b5 65 bb 3e 82 c2 e3 21 66 97 |...|ípµe»>.Âã!f.|
000000e0 63 2b 71 98 35 d0 f7 a2 52 64 7b 59 b0 a7 6f 0f |c+q.5Ð÷¢Rd{Y°§o.|
000000f0 69 18 a8 51 c7 8c 69 75 0d 37 77 e8 9b 15 e0 5c |i.¨QÇ.iu.7wè..à\|
00000100 09 b2 4d a9 04 78 d4 35 b9 ae 31 57 01 5d 46 04 |.²M©.xÔ5¹®1W.]F.|
00000110 07 85 c3 c7 b3 5f 01 2d 53 23 13 c3 86 00 db b7 |..Ãdz_.-S#.Ã..Û·|
00000120 0c d5 95 39 a4 c9 32 72 f4 |.Õ.9¤É2rô|
This means the client cannot see the contents of the payload and must rely on introspection requests to the server to learn about the user if needed.
A common way to encode self-contained tokens is JWT. The payload of a JWT is usually not encrypted (though it can be). This allows the client to make decisions based on the token without needing extra requests to the server.
Self-contained tokens are a common attack vector. If an attacker can modify or create a token that the server accepts, it can lead to unauthorized access or privilege escalation. To prevent this, JWT uses a signature, and the standard ASP.NET Core token is probably signed as well or relies on its encryption to prevent attacks. Self-contained tokens should not contain confidential information when not encrypted.
An advantage of self-contained tokens is that the server only needs to check the token for the following:
Is the signature correct?
Is the issuer trusted?
Is the scope of the token correct?
Has the token expired?
However, just relying on this does not provide a way to explicitly invalidate a token (e.g., when logging off). Many systems offer a way to maintain a blacklist of recently expired tokens and check tokens against that list periodically.
Opaque
Opaque tokens do not contain the principal itself; instead, they have an arbitrary payload that the server uses to identify the principal (for example, a key to a database entry). Opaque tokens are usually shorter, as they only need to be long enough to prevent guessing. If the client wants information about the principal, an introspection call is necessary.
Changing the Default Token Format of ASP.NET Core
Self-contained JWT
Suppose we want to use a JWT to let the client make decisions based on claims. A great use case would be to hide functionality within the client that the user does not have permission to access.
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(opt => {
// allow accessing the token in JavaScript code
opt.Cookie.HttpOnly = false;
opt.TicketDataFormat = new JwtTicketDataFormatService(Settings.JWT_SECRET);
});
class JwtTicketDataFormatService : ISecureDataFormat<AuthenticationTicket>
{
/// ...
}
TicketDataFormat acts as the serializer for the token. The interface requires the following methods to be implemented:
// serialize
string Protect(AuthenticationTicket data);
string Protect(AuthenticationTicket data, string? purpose);
// deserialize
AuthenticationTicket? Unprotect(string? protectedText);
Unprotect(string? protectedText, string? purpose);
AuthenticationTicket primarily wraps the ClaimsPrincipal. The basic JWT handler can be integrated into an ISecureDataFormat<AuthenticationTicket> implementation to issue JWTs instead of the standard encrypted tokens.
Here is what the response looks like when issuing a JWT:
HTTP/1.1 302 Found
Content-Length: 0
Date: Sat, 15 Nov 2025 07:41:08 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Location: /
Pragma: no-cache
Set-Cookie: AuthCookie=eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNobWFjLXNoYTUxMiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiVXNlciIsImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd3MvMjAwOC8wNi9pZGVudGl0eS9jbGFpbXMvcm9sZSI6IlVzZXJzIiwiZXhwIjoxNzYzMjc4ODY5fQ.Hj77lAnUqomXwrJsWqaUL2u_KlTOxi1bAGFPcgCJRECDUOwSkXBIPnarmlBsSvAZEt_Kt_n_o3JFRqNYtkMz0A; path=/; samesite=lax
If you paste this JWT into jwt.io, you can view the claims embedded inside the token.
{
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "User",
"http://schemas.microsoft.com/ws/2008/06/identity/claims/role": "Users",
"exp": 1763278869
}
The Common "Create Your Own Principal" Mistake
This often happens to people who are implementing code to create a ClaimsPrincipal for the first time. There is a constructor where you simply pass in the claims, but the created principal is not authenticated. To make authentication work, you need to specify which claim name points to the user name and also indicate which claim points to group memberships.
var principal = new ClaimsPrincipal(new ClaimsIdentity(
token.Claims, CookieAuthenticationDefaults.AuthenticationScheme, ClaimTypes.Name, ClaimTypes.Role));
Opaque Token
You might want shorter tokens to reduce network traffic, especially if your clients aren't interested in the user's claims.
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(opt => {
opt.TicketDataFormat = new OpaqueTicketDataFormatService();
});
Here is what the response for the opaque token looks like:
HTTP/1.1 302 Found
Content-Length: 0
Date: Sat, 15 Nov 2025 07:48:26 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Location: /
Pragma: no-cache
Set-Cookie: AuthCookie=F4802EC3F7820AC552EC9F2BDFFF6E24; path=/; samesite=lax
As you might guess, this token is too short to include:
user name
user roles
an additional signature
On the server side, the implementation might involve querying a database or using an in-memory collection to obtain a principal.
My Minimal Solution
I created a small demo project that shows everything discussed in this post. You can find it here. Here are some notes on the implementation:
To switch between different token modes, change
Settings.TOKEN_MODE.OpaqueTicketDataFormatServiceis created before registering it as aTicketDataFormat. This allows it to be registered in the DI container.I changed the cookie name to
AuthCookie. I don't understand why the standard ASP.NET Core cookie reveals what the server is implemented in. This is unnecessary security information that could be exposed to potential attackers.I enabled JavaScript access to the cookie. If you don't need this, disable it to prevent many script-based attacks.
I added some JavaScript to the authenticated page to demonstrate how to decide the payload of the JWT and display the claims.
Conclusion
I hope you found this interesting and that you can use parts of the code if you want to implement minimal forms authentication yourself.