Backend Development with .NET
Session 19
Implementing JWT Auth in .NET
Eng. Seif Mansour  ·  Andalusia Academy
Week 7  ·  2.5 hours
Session Goals

By the end of this session, you will be able to:

  • Configure JWT bearer authentication in ASP.NET Core using AddAuthentication and AddJwtBearer
  • Implement a login endpoint that issues signed JWT tokens from user credentials
  • Protect endpoints with the [Authorize] attribute and verify a 401 is returned without a valid token
  • Read identity claims (user ID, email, role) from the token inside a controller action
  • Connect the issued token to the React frontend via the Authorization: Bearer header
Session Agenda
Time Segment Type Duration
0:00Recap from theory sessionDiscussion10 min
0:10Configure AddAuthentication / AddJwtBearerDemo25 min
0:35Registration & login endpointsDemo30 min
1:05Break10 min
1:15Protecting endpoints with [Authorize]Demo20 min
1:35Reading claims from the tokenDemo15 min
1:50Lab — add auth to the Task APILab40 min
Setup & Configuration
Before any authentication logic can run, ASP.NET Core needs to know the scheme, the validation parameters, and where the signing secret lives. This section wires up the NuGet packages and the middleware pipeline so every incoming request can be authenticated before it reaches your action code.
In this section
  • NuGet packages required
  • JWT settings in appsettings.json
  • Registering the bearer scheme in Program.cs
  • Token validation parameters
  • Middleware order: UseAuthentication before UseAuthorization
NuGet Packages

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 request
  • BCrypt.Net-Next — a well-tested bcrypt implementation for hashing and verifying passwords. Never store plain-text passwords or roll your own hashing
Package version
Install the JwtBearer package version that matches your target framework. For .NET 8 use version 8.x; for .NET 9 use 9.x. A version mismatch causes runtime binding failures that are hard to diagnose.
dotnet add package \
  Microsoft.AspNetCore.Authentication.JwtBearer

dotnet add package BCrypt.Net-Next
Why BCrypt?
BCrypt is a slow adaptive hashing algorithm — its cost factor can be increased as hardware gets faster. Unlike MD5 or SHA-256, it is designed specifically to resist brute-force and rainbow-table attacks on password databases.
JWT Settings in appsettings.Development.json
appsettings.Development.json
{
  "Jwt": {
    "Secret": "your-super-secret-key-at-least-32-characters",
    "Issuer": "TaskManagerApi",
    "Audience": "TaskManagerApiUsers",
    "ExpiryMinutes": 60
  }
}
Never commit the secret
The 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.

Registering JWT Bearer in Program.cs
Program.cs
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.

What Each Validation Parameter Does
Parameter What it checks Effect if invalid
ValidateIssuerToken iss claim matches ValidIssuer401 Unauthorized
ValidateAudienceToken aud claim matches ValidAudience401 Unauthorized
ValidateLifetimeCurrent time is before exp claim401 Unauthorized
ValidateIssuerSigningKeySignature verifies with the configured key401 Unauthorized
All four should be true in production
Disabling any of these checks weakens the security guarantee. Setting ValidateLifetime = false means expired tokens are permanently valid — a major risk if a token is ever compromised. Always keep all four enabled.
Key Concept
"Authentication asks who you are.
Authorization asks what you are allowed to do."
JWT authentication answers the first question by verifying the token's signature and claims. The [Authorize] attribute answers the second. Both must be present in the middleware pipeline and they must run in that order.
Registration & Login Endpoints
With the middleware configured, the next step is to give users a way to register and obtain a token. This section builds the User entity, hashes passwords with BCrypt, and writes the two auth endpoints that every protected API needs.
In this section
  • The User entity
  • Hashing passwords with BCrypt
  • POST /api/auth/register
  • POST /api/auth/login
  • Generating a signed JWT token
The User Entity

The User entity stores only what is needed to authenticate and authorize a caller. Note the naming convention: PasswordHash, not Password.

  • Email — the login identifier; must be unique across all users
  • PasswordHash — the BCrypt output of the plain-text password. The plain-text password is never stored anywhere
  • Role — a string claim embedded in the token, used later for authorization policies
Separate from TaskItem
Keep the User entity in its own file. Mixing auth concerns into the task entity creates coupling that will cause problems when adding roles, refresh tokens, or OAuth later.
Models/User.cs
public class User
{
    public int Id { get; set; }

    public string Email { get; set; } = "";

    public string PasswordHash { get; set; } = "";

    public string Role { get; set; } = "User";
}
DbContext
Add DbSet<User> Users to your AppDbContext and run a migration to create the Users table before implementing the endpoints.
POST /api/auth/register
Controllers/AuthController.cs
[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.

POST /api/auth/login
Controllers/AuthController.cs
[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 });
}
Generic error message
Always return the same message for both "user not found" and "wrong password". Distinguishing between them tells an attacker whether an email address is registered — a user enumeration vulnerability. The client receives only "Invalid credentials." regardless of which check failed.
Generating a Signed JWT Token
Controllers/AuthController.cs — GenerateToken
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.

Request DTOs for Register and Login

Dedicated DTOs keep the controller action signature clean and allow adding FluentValidation rules independently of the User entity.

  • Never bind directly to the User entity — it would expose the PasswordHash and Role fields to over-posting
  • Add [Required] and [EmailAddress] annotations (or FluentValidation rules) to both DTOs so malformed requests are rejected before the action runs
  • The LoginRequest intentionally mirrors RegisterRequest — they may diverge later if registration requires additional fields like a display name
Models/AuthRequests.cs
public 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; } = "";
}
Protecting Endpoints
With tokens being issued, it is time to require them. The [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.
In this section
  • Applying [Authorize] at controller and action level
  • [AllowAnonymous] for public endpoints
  • What a 401 response looks like
  • Testing the protected flow in Postman
Applying [Authorize] to the Tasks Controller

Place [Authorize] on the controller class to protect every action in it by default:

  • Any request without a valid Bearer token receives 401 Unauthorized before the action code runs
  • Add [AllowAnonymous] to individual actions that should remain public (health checks, public read endpoints)
  • The auth controller itself must not carry [Authorize] — otherwise users could not reach the register or login endpoints to obtain a token
Controller-level is the safer default
Applying [Authorize] at the controller level and using [AllowAnonymous] as an exception is safer than the reverse. You cannot accidentally leave an endpoint unprotected.
Controllers/TasksController.cs
[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)
    {
        // ...
    }
}
Testing the Protected Flow in Postman
1 POST /api/auth/register with a JSON body containing email and password. Expect 200 with a success message.
2 POST /api/auth/login with the same credentials. Expect 200 with a token field in the response body. Copy the token value.
3 GET /api/tasks with no Authorization header. Expect 401 Unauthorized — this confirms the endpoint is protected.
4 In Postman, go to the Authorization tab, select Bearer Token, and paste the token. Send the same request again. Expect 200 with the task list.
5 Modify one character in the token (breaking the signature) and send again. Expect 401 — this confirms signature validation is active.
Reading Claims from the Token
Once a request is authenticated, the claims embedded in the token are available on 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.
In this section
  • User.FindFirst() and User.Claims
  • Reading the current user ID from the token
  • The /me endpoint pattern
  • Using the user ID to scope data access
  • Connecting to the React frontend
Reading Claims Inside a Controller Action
Controllers/AuthController.cs
[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 vs User.Claims
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.

Scoping Data Access to the Current User

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:

  • Extract userId from the token claims at the start of the action
  • Filter every query by that user ID — the caller cannot override it
  • Return 403 Forbidden (not 404) if the resource exists but belongs to a different user — this reveals less information about what exists
  • This pattern is the foundation of row-level security in the Task API
Never trust the request body for the user ID
If the create-task request contains a userId 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.
Controllers/TasksController.cs
[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);
}
Connecting the Token to the React Frontend

The same flow the students have consumed as React developers now makes sense from the other side:

  • After a successful login response, store the token in localStorage or a React context (avoid localStorage if XSS is a concern — prefer httpOnly cookies in production)
  • Attach the token to every subsequent request via the Authorization: Bearer <token> header
  • When the server returns 401, clear the stored token and redirect to the login page
  • The exp claim tells the frontend exactly when the token expires — decode it client-side to proactively refresh before the user gets a 401 mid-session
React — api.js (example)
// 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');
}
Lab — Add Auth to the Task API

Implement the complete authentication flow and verify the protected endpoint behaviour end-to-end in Postman.

1 Create the User entity (Id, Email, PasswordHash, Role), add DbSet<User> to AppDbContext, and run a migration
2 Implement POST /api/auth/register — hash the password with BCrypt, reject duplicate emails with 409 Conflict
3 Configure AddAuthentication / AddJwtBearer in Program.cs with all four validation parameters enabled; add UseAuthentication and UseAuthorization in the correct order
4 Implement POST /api/auth/login — verify the password with BCrypt.Verify, return a signed JWT token on success
5 Add [Authorize] to the TasksController; test the full flow in Postman: register → login → copy token → call protected endpoint
6 Verify that calling a protected endpoint without a token returns 401, and that a tampered token also returns 401
Summary
  • AddAuthentication / AddJwtBearer wires up token validation — all four parameters (issuer, audience, lifetime, signing key) should be enabled; middleware order matters: UseAuthentication before UseAuthorization
  • Passwords are stored as BCrypt hashes, never plain textBCrypt.HashPassword on register, BCrypt.Verify on login; the secret value is never retrievable from the hash
  • [Authorize] enforces authentication at the controller or action level — requests without a valid Bearer token receive 401 before action code runs; use [AllowAnonymous] for public endpoints
  • Claims in the token replace database lookups for identityUser.FindFirst(ClaimTypes.NameIdentifier) gives you the user ID inside any protected action without an additional query
  • Scope every data query to the authenticated user's ID from the token — never accept a user ID from the request body or URL for ownership decisions
What's Next

Session 20 — Authorization: Roles & Policies

  • Session 19 authenticated the caller — the API now knows who is making the request. Session 20 adds authorization: deciding what they are allowed to do based on their role or other attributes
  • You will use the Role claim embedded in the token to restrict endpoints to specific roles with [Authorize(Roles = "Admin")]
  • Policy-based authorization allows richer rules beyond roles — for example, "only the owner of a task may delete it" — implemented via custom IAuthorizationRequirement and handler classes
  • The UserId claim added in this session becomes the key for ownership-based policies in Session 20
Before next session
Complete the lab and assignment. Make sure your Task API returns 401 for unauthenticated requests and correctly scopes task queries to the authenticated user's ID from the token claims.
Assignment

Add full JWT authentication to the Task Manager API and verify the complete authenticated flow.

What to build:

  • Implement 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 claims
  • Configure AddAuthentication / AddJwtBearer with all validation parameters and the correct middleware order in Program.cs
  • Protect the entire TasksController with [Authorize] and scope all task queries to the authenticated user's ID read from the token claims

Acceptance criteria:

  • Registering with a valid email and password returns 200; registering again with the same email returns 409
  • Logging in with correct credentials returns a JWT token; incorrect credentials always return 401 with "Invalid credentials."
  • Calling GET /api/tasks without a token returns 401; with a valid token returns 200 with only that user's tasks
  • Calling GET /api/tasks with a tampered token (one character changed) returns 401
  • Creating a task while authenticated stores the correct UserId from the token, not from the request body
Bonus
Add the Swagger UI JWT button so you can authenticate directly in the Swagger UI without switching to Postman. See Session 13 for the AddSecurityDefinition configuration that enables the "Authorize" button in Swagger.
Questions?
Session 19 — Implementing JWT Auth in .NET