TL;DR
- Issue signed 15-minute access tokens for ASP.NET Core Identity users with
JsonWebTokenHandler, the same handler JwtBearer uses to validate them. - Validate strictly: issuer, audience, lifetime, HS256 only, a 30-second clock skew, and a signing key whose length is checked at startup.
- Avoid the classic traps: claim mapping that hides
sub, AddIdentity in an API, and authentication middleware in the wrong order.
Level: Intermediate · Prerequisites: An ASP.NET Core Web API project, basic EF Core, SQL Server or LocalDB · Reading time: 13 min
Your Angular app and a mobile client both need to call Store.Api, and the ticket says “add login”. Browser cookies tied to one domain do not fit both clients, so the API will issue its own JSON Web Tokens (JWT): short-lived, signed strings that the client sends in the Authorization header on every request.
In this guide you store users and password hashes with ASP.NET Core Identity, issue a JWT at login, and validate it on every protected request. The rest of the series builds on the types you add here: AppUser, AppDbContext, JwtOptions, ITokenService, TokenResult, and ICurrentUser.
What you will build#
POST /api/auth/register creates a user with a hashed password in SQL Server.POST /api/auth/login verifies the password, counts failures, locks the account for 15 minutes after five failures, and returns a signed access token.- The token carries
sub, email, role, and jti claims and expires after 15 minutes. - Every request with
Authorization: Bearer <token> is checked for signature, issuer, audience, and lifetime. GET /api/auth/me returns the caller’s id, email, and roles, and returns 401 without a valid token.
Before you start#
- .NET 10, the current LTS. The code also compiles on .NET 8 and 9, but both reach end of support on November 10, 2026; Upgrading a Production API from .NET 8 to .NET 10: What Broke covers the move.
- An ASP.NET Core Web API project named
Store.Api with controllers, builder.Services.AddControllers(), builder.Services.AddProblemDetails(), and implicit usings enabled (the template default). - SQL Server or LocalDB, and the
dotnet-ef tool.
1
2
3
4
5
| dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet tool install --global dotnet-ef
|
These packages ship one major version per .NET release, so keep their major version in line with your target framework (on .NET 8, pass --version with the latest 8.0 patch). JwtBearer brings in Microsoft.IdentityModel.JsonWebTokens and Microsoft.IdentityModel.Tokens, which hold the token classes used below.
How it works#
- Register. Identity hashes the password and stores the user in the
AspNetUsers table. - Login. The API loads the user by email, refuses locked accounts, verifies the hash, and builds a JWT.
- The token. A JWT has three Base64Url parts separated by dots: a header (
"alg": "HS256"), a payload of claims, and a signature. The signature is an HMAC (Hash-based Message Authentication Code) over header and payload, computed with a secret key that only the API knows. - Later requests. The client sends
Authorization: Bearer <token>. - Validation. The JwtBearer handler recomputes the signature with the same key, checks
exp and nbf against the clock (plus a small skew), checks that iss matches your issuer and aud matches your audience. Any failure ends in 401 with a WWW-Authenticate header. - Success. The claims become
HttpContext.User, [Authorize] passes, and your code reads the user id from sub.
1
2
| POST /login -> UserManager (lockout, password hash) -> TokenService -> JWT
GET /me + Bearer JWT -> JwtBearer (signature, exp, iss, aud) -> HttpContext.User -> [Authorize] -> controller
|
A JWT is signed, not encrypted. Anyone holding it can decode the payload and read every claim. The signature only proves that your API issued it and that nobody changed it, so never put secrets or sensitive personal data in claims. Since .NET 8, JwtBearer validates tokens with JsonWebTokenHandler by default instead of the older JwtSecurityTokenHandler, so this guide uses the same handler to create them.
Step 1: Add the AppUser entity#
1
2
3
4
5
6
7
8
9
| // File: Auth/AppUser.cs
using Microsoft.AspNetCore.Identity;
namespace Store.Api.Auth;
public class AppUser : IdentityUser
{
// Add your own profile columns here later, for example DisplayName.
}
|
IdentityUser already has everything login needs: a string Id, UserName, Email, PasswordHash, SecurityStamp, AccessFailedCount, and LockoutEnd. You still create your own subclass on day one, because changing the user type later means touching every UserManager<T> and a migration. Other guides refer to it as Store.Api.Auth.AppUser.
Step 2: Add the Identity DbContext#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // File: Data/AppDbContext.cs
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Store.Api.Auth;
namespace Store.Api.Data;
public class AppDbContext(DbContextOptions<AppDbContext> options)
: IdentityDbContext<AppUser>(options)
{
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}
|
IdentityDbContext<AppUser> maps the users, roles, user-role links, claims, logins, and tokens tables.base.OnModelCreating(builder) must run first. Without it, EF Core fails with The entity type 'IdentityUserLogin<string>' requires a primary key.ApplyConfigurationsFromAssembly picks up every IEntityTypeConfiguration<T> class in the project, so later guides (refresh tokens, orders) add a configuration file instead of editing this one.
Step 3: Add JwtOptions and the Jwt settings#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // File: Auth/JwtOptions.cs
using System.ComponentModel.DataAnnotations;
namespace Store.Api.Auth;
public sealed class JwtOptions
{
public const string SectionName = "Jwt";
[Required]
public string Issuer { get; set; } = string.Empty;
[Required]
public string Audience { get; set; } = string.Empty;
[Required]
public string Key { get; set; } = string.Empty;
[Range(1, 60)]
public int AccessTokenMinutes { get; set; } = 15;
}
|
Add the connection string and the non-secret JWT settings to appsettings.json:
1
2
3
4
5
6
7
8
9
10
| {
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=StoreDb;Trusted_Connection=True;TrustServerCertificate=True"
},
"Jwt": {
"Issuer": "https://localhost:7001",
"Audience": "store-api",
"AccessTokenMinutes": 15
}
}
|
The signing key does not go in that file. Store it in user secrets for development:
1
2
| dotnet user-secrets init
dotnet user-secrets set "Jwt:Key" "$(openssl rand -base64 48)"
|
On Windows without OpenSSL, generate the value in PowerShell 7 with [Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(48)) and paste it into the set command.
- 48 random bytes become a 64-character Base64 string. The API uses the UTF-8 bytes of that string as the HMAC key, so the key is 64 bytes long.
- HS256 needs a key of at least 32 bytes (256 bits). Shorter keys fail at token creation with
IDX10720: Unable to create KeyedHashAlgorithm for algorithm 'HS256'. Step 7 checks the length at startup so you find out before the first login. - In production, set the key through an environment variable (
Jwt__Key) or a secret store. Anyone who has the key can mint valid tokens for any user.
Step 4: Add the token service#
1
2
3
4
5
6
7
8
9
| // File: Auth/ITokenService.cs
namespace Store.Api.Auth;
public interface ITokenService
{
TokenResult CreateAccessToken(AppUser user, IEnumerable<string> roles);
}
public sealed record TokenResult(string AccessToken, DateTimeOffset ExpiresAt);
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
| // File: Auth/TokenService.cs
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
namespace Store.Api.Auth;
public sealed class TokenService : ITokenService
{
private readonly JsonWebTokenHandler _handler = new();
private readonly JwtOptions _options;
private readonly SigningCredentials _credentials;
private readonly TimeProvider _timeProvider;
public TokenService(IOptions<JwtOptions> options, TimeProvider timeProvider)
{
_options = options.Value;
_timeProvider = timeProvider;
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key));
_credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
}
public TokenResult CreateAccessToken(AppUser user, IEnumerable<string> roles)
{
var now = _timeProvider.GetUtcNow();
var expiresAt = now.AddMinutes(_options.AccessTokenMinutes);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id),
new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
claims.AddRange(roles.Select(role => new Claim("role", role)));
var descriptor = new SecurityTokenDescriptor
{
Issuer = _options.Issuer,
Audience = _options.Audience,
Subject = new ClaimsIdentity(claims),
IssuedAt = now.UtcDateTime,
NotBefore = now.UtcDateTime,
Expires = expiresAt.UtcDateTime,
SigningCredentials = _credentials
};
return new TokenResult(_handler.CreateToken(descriptor), expiresAt);
}
}
|
JwtRegisteredClaimNames exists in both Microsoft.IdentityModel.JsonWebTokens and System.IdentityModel.Tokens.Jwt. This guide uses only the first, so the two never collide in one file.sub is the user id, jti is a unique token id you can log or put on a deny list, and every role becomes its own role claim. One role serializes as a string, several as a JSON array.Expires, IssuedAt, and NotBefore come from TimeProvider, so a test can swap in FakeTimeProvider (package Microsoft.Extensions.TimeProvider.Testing) and check expiry without waiting.- The key and credentials are built once in the constructor. The service holds no per-request state, so it is registered as a singleton.
Step 5: Add ICurrentUser#
1
2
3
4
5
6
7
8
9
| // File: Auth/ICurrentUser.cs
namespace Store.Api.Auth;
public interface ICurrentUser
{
string? UserId { get; }
bool IsAuthenticated { get; }
bool IsInRole(string role);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // File: Auth/HttpCurrentUser.cs
using System.Security.Claims;
namespace Store.Api.Auth;
public sealed class HttpCurrentUser(IHttpContextAccessor accessor) : ICurrentUser
{
private ClaimsPrincipal? Principal => accessor.HttpContext?.User;
public string? UserId => Principal?.FindFirst("sub")?.Value;
public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated ?? false;
public bool IsInRole(string role) => Principal?.IsInRole(role) ?? false;
}
|
Controllers can read User directly, but services (order placement, auditing) should not depend on HttpContext. They take ICurrentUser instead, and tests pass a fake. UserId reads the raw sub claim, which only works because Step 7 turns off claim type mapping. Outside a request (a background job), HttpContext is null and every member returns “anonymous”.
Step 6: Add the request contracts and AuthController#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // File: Auth/AuthContracts.cs
using System.ComponentModel.DataAnnotations;
namespace Store.Api.Auth;
public sealed record RegisterRequest(
[Required, EmailAddress] string Email,
[Required] string Password);
public sealed record LoginRequest(
[Required, EmailAddress] string Email,
[Required] string Password);
public sealed record MeResponse(string UserId, string? Email, string[] Roles);
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
| // File: Auth/AuthController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace Store.Api.Auth;
[ApiController]
[Route("api/auth")]
public sealed class AuthController(
UserManager<AppUser> userManager,
ITokenService tokenService,
ICurrentUser currentUser) : ControllerBase
{
[HttpPost("register")]
[AllowAnonymous]
public async Task<IActionResult> Register(RegisterRequest request)
{
var user = new AppUser { UserName = request.Email, Email = request.Email };
var result = await userManager.CreateAsync(user, request.Password);
if (!result.Succeeded)
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(error.Code, error.Description);
}
return ValidationProblem(ModelState);
}
return StatusCode(StatusCodes.Status201Created, new { id = user.Id, email = user.Email });
}
[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login(LoginRequest request)
{
var user = await userManager.FindByEmailAsync(request.Email);
if (user is null || await userManager.IsLockedOutAsync(user))
{
return InvalidCredentials();
}
if (!await userManager.CheckPasswordAsync(user, request.Password))
{
await userManager.AccessFailedAsync(user);
return InvalidCredentials();
}
await userManager.ResetAccessFailedCountAsync(user);
var roles = await userManager.GetRolesAsync(user);
return Ok(tokenService.CreateAccessToken(user, roles));
}
[HttpGet("me")]
[Authorize]
public IActionResult Me()
{
var roles = User.FindAll("role").Select(c => c.Value).ToArray();
return Ok(new MeResponse(currentUser.UserId!, User.FindFirst("email")?.Value, roles));
}
private ObjectResult InvalidCredentials() =>
Problem(statusCode: StatusCodes.Status401Unauthorized, title: "Invalid email or password.");
}
|
- One error for every failure. Unknown email, locked account, and wrong password all return the same 401, so the login form cannot be used to discover which emails have accounts. The trade-off: a locked-out user sees a generic message. If your product prefers a “try again in 15 minutes” message, return it only after the password check succeeds.
- Lockout.
AccessFailedAsync increments AccessFailedCount. When it reaches MaxFailedAccessAttempts, Identity sets LockoutEnd and resets the counter. IsLockedOutAsync runs before the password check, so a locked account rejects even the correct password until the lockout ends. - Reset on success.
ResetAccessFailedCountAsync stops old failures from adding up across days. - UserName = Email. Identity requires a unique user name. Using the email keeps one identifier.
- Me needs no database call. Everything it returns comes from the validated token.
[AllowAnonymous] changes nothing yet, but it keeps these actions open once Role-Based and Policy-Based Authorization in ASP.NET Core adds a fallback policy that requires login everywhere.
Step 7: Register everything in Program.cs#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
| // File: Program.cs (usings at the top, the rest before builder.Build())
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Store.Api.Auth;
using Store.Api.Data;
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services
.AddIdentityCore<AppUser>(options =>
{
options.User.RequireUniqueEmail = true;
options.Password.RequiredLength = 8;
options.Password.RequireDigit = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Lockout.AllowedForNewUsers = true;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
builder.Services.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection(JwtOptions.SectionName))
.ValidateDataAnnotations()
.Validate(o => Encoding.UTF8.GetByteCount(o.Key) >= 32,
"Jwt:Key must be at least 32 bytes (256 bits) for HS256.")
.ValidateOnStart();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<ITokenService, TokenService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, HttpCurrentUser>();
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();
builder.Services
.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
.Configure<IOptions<JwtOptions>>((bearer, jwtOptions) =>
{
var jwt = jwtOptions.Value;
bearer.MapInboundClaims = false;
bearer.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwt.Issuer,
ValidateAudience = true,
ValidAudience = jwt.Audience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Key)),
ValidAlgorithms = [SecurityAlgorithms.HmacSha256],
NameClaimType = "sub",
RoleClaimType = "role",
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization();
|
1
2
3
| // File: Program.cs (in the pipeline, after builder.Build() and before app.MapControllers())
app.UseAuthentication();
app.UseAuthorization();
|
AddIdentityCore, not AddIdentity. AddIdentityCore registers UserManager, password hashing, and validators only. AddIdentity also makes the Identity cookie the default authentication scheme, so JwtBearer never looks at the token. On .NET 8 and 9 that shows up as redirects to a login page that does not exist; on .NET 10, [ApiController] endpoints return 401 even for a valid token. AddRoles<IdentityRole> adds RoleManager and role storage, and AddDefaultTokenProviders enables the email and password reset tokens used by later guides.- Options validation.
ValidateDataAnnotations enforces [Required] and [Range], the extra Validate call enforces the 32-byte minimum, and ValidateOnStart runs both when the app starts instead of at the first login. - One source of truth.
Configure<IOptions<JwtOptions>> feeds the same validated options into JwtBearer, so issuer, audience, and key cannot drift between the code that signs and the code that validates. MapInboundClaims = false. By default, JwtBearer renames sub to http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier and role to a similar long URI. Turning that off keeps claim names exactly as they appear in the token.NameClaimType and RoleClaimType. With mapping off, you tell the identity which claims mean “name” and “role”. Now User.Identity.Name returns the user id and User.IsInRole("Admin") reads role claims.ValidAlgorithms. Accepts only HS256, so a token that claims another algorithm in its header is rejected.ClockSkew. Defaults to 5 minutes, which quietly makes a 15-minute token live for 20. Thirty seconds covers normal clock drift between servers.- Order.
UseAuthentication() must come before UseAuthorization(). Otherwise User is still anonymous when authorization runs.
Step 8: Create the database#
1
2
| dotnet ef migrations add InitialIdentity
dotnet ef database update
|
The migration creates AspNetUsers, AspNetRoles, AspNetUserRoles, AspNetUserClaims, AspNetUserLogins, AspNetUserTokens, and AspNetRoleClaims. Applying migrations in production deserves its own process. EF Core Code-First Migrations: From First Migration to Production Deploy covers it.
Try it#
Save this as Store.Api.http. Request variables (# @name) work in Visual Studio 2026, Visual Studio 2022 17.12 or later, and the VS Code REST Client extension.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| @baseUrl = https://localhost:7001
### Register
POST {{baseUrl}}/api/auth/register
Content-Type: application/json
{ "email": "ada@example.com", "password": "Passw0rd!" }
### Login
# @name login
POST {{baseUrl}}/api/auth/login
Content-Type: application/json
{ "email": "ada@example.com", "password": "Passw0rd!" }
### Who am I (200)
GET {{baseUrl}}/api/auth/me
Authorization: Bearer {{login.response.body.$.accessToken}}
### No token (401)
GET {{baseUrl}}/api/auth/me
### Wrong password (401)
POST {{baseUrl}}/api/auth/login
Content-Type: application/json
{ "email": "ada@example.com", "password": "wrong" }
|
Expected results:
1
2
3
4
5
| Register 201 {"id":"12ea3a76-3f24-4179-895c-fd8d624c3397","email":"ada@example.com"}
Login 200 {"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIi(shortened)","expiresAt":"2026-10-12T21:05:41.5891408+00:00"}
Who am I 200 {"userId":"12ea3a76-3f24-4179-895c-fd8d624c3397","email":"ada@example.com","roles":[]}
No token 401, empty body, header WWW-Authenticate: Bearer
Wrong password 401 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Invalid email or password.","status":401,"traceId":"00-d861aa6f..."}
|
Change one character in the middle of the token and call /api/auth/me again. The response is 401 with WWW-Authenticate: Bearer error="invalid_token", error_description="The signature key was not found", because no key produces that signature. An expired token gives error_description="The token expired at '...'" instead. Send the wrong password five times and then the correct one: you get 401 until the 15-minute lockout ends.
Finally, decode the payload to see that it is readable by anyone:
1
2
3
4
| TOKEN="paste-the-access-token-here"
p=$(echo "$TOKEN" | cut -d '.' -f 2 | tr '_-' '/+')
while [ $(( ${#p} % 4 )) -ne 0 ]; do p="$p="; done
echo "$p" | base64 -d
|
1
| {"sub":"12ea3a76-3f24-4179-895c-fd8d624c3397","email":"ada@example.com","jti":"991d6580-36e2-425b-85a2-8e8b4b0f4bac","aud":"store-api","iss":"https://localhost:7001","exp":1791839141,"iat":1791838241,"nbf":1791838241}
|
Production checklist#
- Load
Jwt:Key from an environment variable or a secret store such as Azure Key Vault, and never commit it. - Keep access tokens short and add refresh tokens instead of raising
AccessTokenMinutes. - Rate-limit
/api/auth/login and /api/auth/register with the built-in rate limiter (AddRateLimiter), which also slows down email probing by response timing. - Serve the API over HTTPS only and enable HTTP Strict Transport Security (
app.UseHsts()) outside development. - Plan key rotation:
TokenValidationParameters.IssuerSigningKeys accepts several keys, so you can validate old and new tokens during the switch. - Move to an asymmetric algorithm (RS256 or ES256) when other services must validate tokens but must not be able to issue them.
- Never log full tokens. Log
jti and sub instead.
Common pitfalls#
UseAuthorization() before UseAuthentication(). Every protected endpoint returns 401 because the user is still anonymous when authorization runs. Put authentication first.- Leaving claim mapping on.
FindFirst("sub") returns null because the claim was renamed to the long nameidentifier URI. Set MapInboundClaims = false and the two claim types. - Calling
AddIdentity in an API. The Identity cookie becomes the default scheme. On .NET 8 and 9, unauthenticated calls get a 302 redirect to /Account/Login; on .NET 10, API endpoints return 401 even with a valid bearer token. Use AddIdentityCore. - A short signing key. Login fails with
IDX10720 at runtime. The startup validation in Step 7 catches it earlier, and openssl rand -base64 48 avoids it. - Issuer or audience drift. A trailing slash in one place produces 401 with “The issuer … is invalid”. Read both values from the same
JwtOptions, as this guide does. - Casting to
JwtSecurityToken in JwtBearer events. Since .NET 8, context.SecurityToken is a JsonWebToken, so the cast returns null. Cast to JsonWebToken instead.
- Refresh Tokens in ASP.NET Core: Rotation, Reuse Detection, and Logout
- Role-Based and Policy-Based Authorization in ASP.NET Core
- Adding a JWT Authorize Button to Swagger UI
- Password Reset by Email with ASP.NET Core Identity
Part of The .NET Implementation Playbook. Next in the series: Adding a JWT Authorize Button to Swagger UI (coming soon).
Get the next articles by email: subscribe to the newsletter.
How this article was made: drafted with AI assistance, then reviewed by Softinbit Technology before publishing.