By the end of this session, you will be able to:
AddAuthentication and AddJwtBearer[Authorize] attribute and verify a 401 is returned without a valid tokenAuthorization: Bearer header| Time | Segment | Type | Duration |
|---|---|---|---|
| 0:00 | Recap from theory session | Discussion | 10 min |
| 0:10 | Configure AddAuthentication / AddJwtBearer | Demo | 25 min |
| 0:35 | Registration & login endpoints | Demo | 30 min |
| 1:05 | Break | — | 10 min |
| 1:15 | Protecting endpoints with [Authorize] | Demo | 20 min |
| 1:35 | Reading claims from the token | Demo | 15 min |
| 1:50 | Lab — add auth to the Task API | Lab | 40 min |
Two packages are needed. Install both before writing any configuration code:
Microsoft.AspNetCore.Authentication.JwtBearer — the ASP.NET Core middleware that validates incoming Bearer tokens on every protected requestBCrypt.Net-Next — a well-tested bcrypt implementation for hashing and verifying passwords. Never store plain-text passwords or roll your own hashingdotnet add package \
Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package BCrypt.Net-Next
{
"Jwt": {
"Secret": "your-super-secret-key-at-least-32-characters",
"Issuer": "TaskManagerApi",
"Audience": "TaskManagerApiUsers",
"ExpiryMinutes": 60
}
}
Jwt:Secret value must be at least 32 characters for HMAC-SHA256. In production, store it in an environment variable or a secrets manager — never in a file committed to version control. Use dotnet user-secrets for local development instead of appsettings.Development.json.
Issuer and Audience are validated on every incoming token to prevent tokens issued by another service from being accepted by this API.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(
builder.Configuration["Jwt:Secret"]!))
};
});
app.UseAuthentication(); // must come before UseAuthorization
app.UseAuthorization();
Middleware order matters: UseAuthentication populates HttpContext.User; UseAuthorization then decides whether the authenticated user is allowed to call the endpoint.
| Parameter | What it checks | Effect if invalid |
|---|---|---|
ValidateIssuer | Token iss claim matches ValidIssuer | 401 Unauthorized |
ValidateAudience | Token aud claim matches ValidAudience | 401 Unauthorized |
ValidateLifetime | Current time is before exp claim | 401 Unauthorized |
ValidateIssuerSigningKey | Signature verifies with the configured key | 401 Unauthorized |
ValidateLifetime = false means expired tokens are permanently valid — a major risk if a token is ever compromised. Always keep all four enabled.
[Authorize] attribute answers the second. Both must be present in the middleware pipeline and they must run in that order.
The User entity stores only what is needed to authenticate and authorize a caller. Note the naming convention: PasswordHash, not Password.
public class User
{
public int Id { get; set; }
public string Email { get; set; } = "";
public string PasswordHash { get; set; } = "";
public string Role { get; set; } = "User";
}
DbSet<User> Users to your AppDbContext and run a migration to create the Users table before implementing the endpoints.
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private readonly AppDbContext _context;
private readonly IConfiguration _config;
public AuthController(AppDbContext context, IConfiguration config)
=> (_context, _config) = (context, config);
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest req)
{
if (await _context.Users.AnyAsync(u => u.Email == req.Email))
return Conflict(new { message = "Email already in use." });
var user = new User
{
Email = req.Email,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password),
Role = "User"
};
_context.Users.Add(user);
await _context.SaveChangesAsync();
return Ok(new { message = "Registration successful." });
}
}
BCrypt.HashPassword handles salt generation automatically. The resulting hash is a self-contained string that includes the algorithm, cost factor, salt, and hash — everything needed to verify the password later.
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest req)
{
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Email == req.Email);
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
return Unauthorized(new { message = "Invalid credentials." });
var token = GenerateToken(user);
return Ok(new { token });
}
private string GenerateToken(User user)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Role, user.Role)
};
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: _config["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(
int.Parse(_config["Jwt:ExpiryMinutes"]!)),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
Claims embedded here (NameIdentifier, Email, Role) are readable inside any [Authorize]-protected action via User.FindFirst(...) without another database query.
Dedicated DTOs keep the controller action signature clean and allow adding FluentValidation rules independently of the User entity.
User entity — it would expose the PasswordHash and Role fields to over-posting[Required] and [EmailAddress] annotations (or FluentValidation rules) to both DTOs so malformed requests are rejected before the action runsLoginRequest intentionally mirrors RegisterRequest — they may diverge later if registration requires additional fields like a display namepublic class RegisterRequest
{
[Required]
[EmailAddress]
public string Email { get; set; } = "";
[Required]
[MinLength(8)]
public string Password { get; set; } = "";
}
public class LoginRequest
{
[Required]
[EmailAddress]
public string Email { get; set; } = "";
[Required]
public string Password { get; set; } = "";
}
[Authorize] attribute instructs the authorization middleware to reject any request that does not present a valid Bearer token. This section covers placement, scope, and how to allow anonymous access to specific endpoints.Place [Authorize] on the controller class to protect every action in it by default:
[AllowAnonymous] to individual actions that should remain public (health checks, public read endpoints)[Authorize] — otherwise users could not reach the register or login endpoints to obtain a token[Authorize] at the controller level and using [AllowAnonymous] as an exception is safer than the reverse. You cannot accidentally leave an endpoint unprotected.
[Authorize]
[ApiController]
[Route("api/tasks")]
public class TasksController : ControllerBase
{
// All actions in this controller
// require a valid Bearer token.
[HttpGet]
public async Task<IActionResult> GetAll()
{
// ...
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
// ...
}
}
POST /api/auth/register with a JSON body containing email and password. Expect 200 with a success message.
POST /api/auth/login with the same credentials. Expect 200 with a token field in the response body. Copy the token value.
GET /api/tasks with no Authorization header. Expect 401 Unauthorized — this confirms the endpoint is protected.
HttpContext.User. Controller actions can read the user ID or email from claims instead of passing them as query or body parameters — which would be a security risk.[Authorize]
[HttpGet("me")]
public IActionResult GetCurrentUser()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var email = User.FindFirst(ClaimTypes.Email)?.Value;
var role = User.FindFirst(ClaimTypes.Role)?.Value;
return Ok(new { userId, email, role });
}
User.FindFirst(type) returns the first claim of the given type — use it when you expect exactly one value (user ID, email). User.FindAll(type) returns all claims of that type — needed if a user can have multiple roles. User.Claims exposes the full collection if you need to inspect everything.
The ?. null-conditional operator is important here — if the middleware is misconfigured and FindFirst returns null, the action returns null values rather than throwing a NullReferenceException.
Reading the user ID from the token — rather than from a query parameter — prevents a user from requesting another user's data by changing a number in the URL:
userId from the token claims at the start of the actionuserId field, ignore it. The real user ID comes from the token. Accepting it from the body would let any authenticated user create tasks on behalf of another user.
[Authorize]
[HttpGet]
public async Task<IActionResult> GetAll()
{
var userIdStr = User.FindFirst(
ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(userIdStr, out var userId))
return Unauthorized();
var tasks = await _context.Tasks
.Where(t => t.UserId == userId)
.ToListAsync();
return Ok(tasks);
}
The same flow the students have consumed as React developers now makes sense from the other side:
localStorage or a React context (avoid localStorage if XSS is a concern — prefer httpOnly cookies in production)Authorization: Bearer <token> headerexp claim tells the frontend exactly when the token expires — decode it client-side to proactively refresh before the user gets a 401 mid-session// After login, store the token
localStorage.setItem('token', data.token);
// Attach to every request
const token = localStorage.getItem('token');
const response = await fetch('/api/tasks', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
// Handle 401 — token expired or invalid
if (response.status === 401) {
localStorage.removeItem('token');
navigate('/login');
}
Implement the complete authentication flow and verify the protected endpoint behaviour end-to-end in Postman.
User entity (Id, Email, PasswordHash, Role), add DbSet<User> to AppDbContext, and run a migration
POST /api/auth/register — hash the password with BCrypt, reject duplicate emails with 409 Conflict
AddAuthentication / AddJwtBearer in Program.cs with all four validation parameters enabled; add UseAuthentication and UseAuthorization in the correct order
POST /api/auth/login — verify the password with BCrypt.Verify, return a signed JWT token on success
[Authorize] to the TasksController; test the full flow in Postman: register → login → copy token → call protected endpoint
UseAuthentication before UseAuthorizationBCrypt.HashPassword on register, BCrypt.Verify on login; the secret value is never retrievable from the hash[AllowAnonymous] for public endpointsUser.FindFirst(ClaimTypes.NameIdentifier) gives you the user ID inside any protected action without an additional querySession 20 — Authorization: Roles & Policies
Role claim embedded in the token to restrict endpoints to specific roles with [Authorize(Roles = "Admin")]IAuthorizationRequirement and handler classesUserId claim added in this session becomes the key for ownership-based policies in Session 20Add full JWT authentication to the Task Manager API and verify the complete authenticated flow.
What to build:
POST /api/auth/register and POST /api/auth/login with BCrypt password hashing and a GenerateToken helper that embeds the user ID, email, and role as claimsAddAuthentication / AddJwtBearer with all validation parameters and the correct middleware order in Program.csTasksController with [Authorize] and scope all task queries to the authenticated user's ID read from the token claimsAcceptance criteria:
GET /api/tasks without a token returns 401; with a valid token returns 200 with only that user's tasksGET /api/tasks with a tampered token (one character changed) returns 401UserId from the token, not from the request bodyAddSecurityDefinition configuration that enables the "Authorize" button in Swagger.