12 KiB
Guide 13: JWT Authentication in ASP.NET Core
What is JWT Authentication?
Authentication answers the question "who are you?" Before your API processes a request, it needs to verify the caller's identity — is this really Dr. Smith, or is someone pretending to be her?
JWT (JSON Web Token, pronounced "jot") is one of the most common ways to authenticate API requests. Here's how it works:
- The user sends their username and password to a login endpoint
- The server verifies the credentials and creates a token — a long string that encodes the user's identity
- The server sends the token back to the client (e.g., a browser or mobile app)
- On every subsequent request, the client sends the token in the
Authorizationheader - The server validates the token and extracts the user's identity from it
A JWT has three parts separated by dots: header.payload.signature
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4ifQ.SflKxwRJSMeKKF2QT4fwpM...
- Header: Metadata (which signing algorithm is used)
- Payload: The actual data — user ID, role, expiration time, etc. These key-value pairs are called claims
- Signature: A cryptographic hash of the header + payload, signed with a secret key. If anyone tampers with the payload (e.g., changes the role from "Nurse" to "Admin"), the signature won't match and the server rejects the token
Why JWT instead of sessions? With session-based auth, the server stores session data in memory or a database — every request requires a server-side lookup. With JWT, all the information is embedded in the token itself. The server just validates the signature — no database query needed. This makes JWT ideal for stateless APIs.
How JWT Works in This Project
┌──────────┐ POST /api/auth/login ┌──────────────┐
│ Dashboard│ ──────────────────────────► │ AuthService │
│ (Vue.js) │ { username, password } │ │
│ │ │ 1. Verify pwd │
│ │ ◄────────────────────────── │ 2. Generate │
│ │ { token, expires, ... } │ JWT token │
└──────────┘ └──────────────┘
│
│ Authorization: Bearer eyJhbG...
▼
┌──────────────┐ validate token ┌──────────────────┐
│ Any API │ ◄───────────────── │ JwtBearer │
│ Endpoint │ extract claims │ Middleware │
│ │ │ (checks sig, │
│ Knows: who │ │ expiry, issuer) │
│ the user is │ └──────────────────┘
└──────────────┘
Step 1: Configuration
JwtOptions
public class JwtOptions
{
public const string Section = "Jwt";
public string Issuer { get; set; } = "VigilCareClinical";
public string Audience { get; set; } = "VigilCareClinical.Dashboard";
public string SigningKey { get; set; } = null!;
public int ExpirationMinutes { get; set; } = 480; // 8 hours
}
| Setting | Purpose |
|---|---|
Issuer |
Who created the token — validated on every request to ensure the token came from this server |
Audience |
Who the token is intended for — prevents a token meant for a different service from being accepted |
SigningKey |
The secret key used to sign and verify tokens. Must be at least 256 bits (32 bytes) for HMAC-SHA256 |
ExpirationMinutes |
How long the token is valid. After 480 minutes (8 hours), the token is rejected and the user must log in again |
appsettings.json
{
"Jwt": {
"Issuer": "VigilCareClinical",
"Audience": "VigilCareClinical.Dashboard",
"SigningKey": "DEV-ONLY-REPLACE-WITH-256-BIT-SECRET-IN-PRODUCTION-abc123xyz",
"ExpirationMinutes": 480
}
}
Security note: The signing key in appsettings.json is for development only. In production, this would come from an environment variable or a secret manager (like Azure Key Vault or AWS Secrets Manager), never from a file committed to version control.
Startup Validation
The application fails fast if the signing key is missing or too short:
var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()!;
if (string.IsNullOrWhiteSpace(jwtOptions.SigningKey)
|| Encoding.UTF8.GetByteCount(jwtOptions.SigningKey) < 32)
throw new InvalidOperationException(
"Jwt:SigningKey must be configured and at least 256 bits (32 bytes) for HMAC-SHA256.");
This prevents the application from starting with an insecure key. HMAC-SHA256 requires at least 256 bits — anything shorter is cryptographically weak.
Step 2: Registering JWT Bearer Authentication
In Program.cs:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtOptions.Issuer,
ValidAudience = jwtOptions.Audience,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtOptions.SigningKey))
};
});
What does each validation flag do?
| Flag | What It Checks | What Happens If It Fails |
|---|---|---|
ValidateIssuer |
Token's iss claim matches ValidIssuer |
Rejects tokens from other servers |
ValidateAudience |
Token's aud claim matches ValidAudience |
Rejects tokens meant for other services |
ValidateLifetime |
Token hasn't expired (current time < exp claim) |
Forces re-login after 8 hours |
ValidateIssuerSigningKey |
The signature matches the configured key | Rejects tampered or forged tokens |
What is SymmetricSecurityKey? In symmetric cryptography, the same key is used to both sign and verify. The server uses this key to create the signature when generating the token, and to verify the signature when validating incoming tokens. This is simpler than asymmetric (public/private key) cryptography but requires the key to remain secret.
The middleware is activated later in the pipeline:
app.UseAuthentication(); // Reads the token, validates it, sets HttpContext.User
app.UseAuthorization(); // Checks if the authenticated user has the required permissions
Step 3: Generating Tokens (Login)
The AuthService handles login and token generation:
public class AuthService : IAuthService
{
private readonly AppDbContext _db;
private readonly JwtOptions _jwt;
public async Task<LoginResponse> LoginAsync(LoginRequest req)
{
// 1. Find the user by username
var user = await _db.ClinicalUsers
.FirstOrDefaultAsync(u => u.Username == req.Username && u.IsActive);
// 2. Verify the password using BCrypt
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
throw new ValidationException("Invalid username or password.",
"INVALID_CREDENTIALS");
// 3. Record the login time
user.LastLoginAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync();
// 4. Write an audit log entry
_db.ClinicalAuditLogs.Add(new ClinicalAuditLog
{
Action = AuditAction.UserLogin,
EntityType = "ClinicalUser",
EntityId = user.Id,
UserId = user.Id,
UserDisplayName = user.DisplayName,
});
await _db.SaveChangesAsync();
// 5. Generate the JWT token
var expires = DateTimeOffset.UtcNow.AddMinutes(_jwt.ExpirationMinutes);
var token = GenerateToken(user, expires);
return new LoginResponse(token, expires, user.Id, user.Username,
user.DisplayName, user.Role.ToDbString());
}
}
Token Generation
private string GenerateToken(ClinicalUser user, DateTimeOffset expires)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim("display_name", user.DisplayName),
new Claim("clinical_role", user.Role.ToDbString()),
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.SigningKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _jwt.Issuer,
audience: _jwt.Audience,
claims: claims,
expires: expires.UtcDateTime,
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
What are claims? Claims are key-value pairs embedded in the token's payload. They describe the authenticated user:
| Claim | Value | Purpose |
|---|---|---|
NameIdentifier |
User's GUID | Unique user ID for database lookups |
Name |
"dr.smith" |
Username for logging |
display_name |
"Dr. Sarah Smith" |
Human-readable name for the UI |
clinical_role |
"Physician" |
Role for permission checks (used by the authorization system in Guide 14) |
The claims are not encrypted — anyone can decode a JWT and read the payload (it's just base64). The signature ensures the claims haven't been tampered with, but it doesn't hide them. Never put secrets (passwords, API keys) in JWT claims.
Password Verification with BCrypt
BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)
What is BCrypt? BCrypt is a password hashing algorithm specifically designed to be slow. Why slow? Because if an attacker steals the database, they'll try to crack passwords by hashing millions of guesses. BCrypt's configurable "cost factor" (default 12) makes each hash attempt take ~250ms — fast enough for a single login, but impossibly slow for brute-force attacks (at 250ms each, trying 1 million passwords would take 70 hours).
Passwords are never stored in plaintext — only the BCrypt hash. Verify() hashes the provided password and compares it to the stored hash.
Step 4: How the Dashboard Uses the Token
The Vue.js dashboard stores the token after login and includes it in every API request:
GET /api/encounters HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
The Bearer prefix is a standard convention that tells the server "the value after this space is a JWT token."
When the token expires (after 8 hours), the API returns 401 Unauthorized, and the dashboard redirects the user to the login page.
Step 5: The Fallback Policy
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
What is the fallback policy? It applies to any endpoint that doesn't have an explicit authorization attribute. By setting it to RequireAuthenticatedUser(), every endpoint in the API requires a valid JWT token by default. Endpoints that should be publicly accessible (like health checks) must explicitly opt out with .AllowAnonymous().
This is a security-by-default approach — if a developer forgets to add an authorization attribute to a new endpoint, it's protected rather than exposed.
Key Takeaways
- JWT is stateless authentication — the token contains all the user info needed, so the server doesn't need to look up a session database on every request
- The signing key is the most important secret — anyone who knows the key can forge tokens for any user. Keep it out of source control.
- Claims carry identity, not permissions — the token contains the user's role (
clinical_role), and the authorization system (Guide 14) maps that role to permissions at request time - Token expiration forces periodic re-authentication — 8 hours matches a clinical shift. After that, the user must log in again.
- BCrypt protects passwords at rest — even if the database is compromised, passwords can't be reversed from their hashes
- The fallback policy ensures no endpoint is accidentally left unprotected — security by default, with explicit opt-out for public endpoints