Back to blog
Backend Systemsbeginner

JWT Auth from Scratch in ASP.NET Core

Understand JWT authentication fundamentals: how tokens work, how to issue and validate them in ASP.NET Core, and what to watch out for in production.

Asma HafeezApril 17, 20264 min read
dotnetjwtauthenticationaspnet-coresecurity
Share:š•

JWT Auth from Scratch in ASP.NET Core

JWT (JSON Web Token) is the most common authentication mechanism for REST APIs. This guide explains how it works and how to implement it cleanly in ASP.NET Core.


How JWT Works

1. Client sends credentials: POST /auth/login { email, password }
2. Server validates, creates JWT, sends it back
3. Client stores token (memory or localStorage)
4. Client sends token in every request: Authorization: Bearer 
5. Server validates token on each request — no database lookup needed

A JWT has three parts separated by dots:

eyJhbGciOiJIUzI1NiJ9  .  eyJzdWIiOiIxMjMifQ  .  signature
   Header (base64)            Payload (base64)      Signature

The server signs the token with a secret key. Any modification invalidates the signature.


Install the Package

Bash
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Configure in Program.cs

C#
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

// JWT settings from appsettings.json
var jwtKey    = builder.Configuration["Jwt:Key"]!;
var jwtIssuer = builder.Configuration["Jwt:Issuer"]!;

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer           = true,
            ValidateAudience         = true,
            ValidateLifetime         = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer              = jwtIssuer,
            ValidAudience            = jwtIssuer,
            IssuerSigningKey         = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
            ClockSkew                = TimeSpan.Zero  // no grace period — token expires exactly at exp
        };
    });

builder.Services.AddAuthorization();
var app = builder.Build();

app.UseAuthentication();  // must come before UseAuthorization
app.UseAuthorization();

appsettings.json

JSON
{
  "Jwt": {
    "Key": "your-secret-key-minimum-32-characters-long",
    "Issuer": "https://yourapp.com"
  }
}

Token Service

C#
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;
using System.Text;

public class TokenService
{
    private readonly IConfiguration _config;

    public TokenService(IConfiguration config) => _config = config;

    public string CreateToken(int userId, string email, string role)
    {
        var key = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));

        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
            new Claim(JwtRegisteredClaimNames.Email, email),
            new Claim(ClaimTypes.Role, role),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        };

        var token = new JwtSecurityToken(
            issuer:   _config["Jwt:Issuer"],
            audience: _config["Jwt:Issuer"],
            claims:   claims,
            expires:  DateTime.UtcNow.AddHours(1),
            signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)
        );

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

Auth Endpoints

C#
app.MapPost("/auth/login", async (LoginRequest req, TokenService tokens, IUserService users) =>
{
    var user = await users.ValidateAsync(req.Email, req.Password);
    if (user is null) return Results.Unauthorized();

    var token = tokens.CreateToken(user.Id, user.Email, user.Role);
    return Results.Ok(new { token, expiresIn = 3600 });
});

// Protected endpoint
app.MapGet("/me", (HttpContext ctx) =>
{
    var userId = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
    var email  = ctx.User.FindFirstValue(ClaimTypes.Email);
    return Results.Ok(new { userId, email });
}).RequireAuthorization();

// Role-protected endpoint
app.MapDelete("/admin/users/{id}", (int id) => Results.Ok())
    .RequireAuthorization(policy => policy.RequireRole("Admin"));

record LoginRequest(string Email, string Password);

Reading Claims in Controllers

C#
[ApiController]
[Authorize]
[Route("api/[controller]")]
public class ProfileController : ControllerBase
{
    [HttpGet]
    public IActionResult Get()
    {
        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
        var email  = User.FindFirstValue(ClaimTypes.Email);
        var role   = User.FindFirstValue(ClaimTypes.Role);

        return Ok(new { UserId = userId, Email = email, Role = role });
    }
}

Production Checklist

āœ“ Secret key is at least 32 characters — store in Azure Key Vault or env var
āœ“ Set ClockSkew = TimeSpan.Zero — predictable expiry
āœ“ Use short-lived access tokens (15–60 min) + refresh tokens
āœ“ Include jti (JWT ID) claim for token revocation
āœ“ Validate audience — prevents token reuse across services
āœ“ Use HTTPS only — JWTs in plaintext are readable
āœ“ Don't store sensitive data in the payload — it's base64, not encrypted

Key Takeaways

  1. JWT is stateless — no database lookup per request (the signature proves authenticity)
  2. The payload is base64-encoded, not encrypted — never put passwords or sensitive PII in claims
  3. Short expiry + refresh tokens is the production pattern — not long-lived access tokens
  4. UseAuthentication() must come before UseAuthorization() in the middleware pipeline
  5. Store the secret key in environment variables or a secrets manager — never in source code

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.