Skip to main content

Signing In with JWT: Passwords, Tokens, and 401 – Web API | Lesson 85

Published / updated

Introduction​

Until now, anyone who knew the address could read every student, take attendance, or record a payment. The SMS had no idea who was calling.

This lesson adds signing in. A user sends a user name and password once and gets an access token. Every later request carries that token, and the API checks it before doing anything.

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

  • Store passwords as salted hashes, never as text.
  • Issue a JWT access token when a user signs in, and explain what is inside it.
  • Check the token on every request with ASP.NET Core's JWT bearer authentication.
  • Protect every endpoint by default, and open the few that must be public.
  • Tell 401 from 403, and answer both in the SMS response shape.

Why this matters​

A school's API holds children's names, birthdays, marks, and family phone numbers. An open API leaks all of it to anyone who finds the address.

Sign-in is also the base for everything that follows in this module. Before the API can decide what a teacher may do (Lesson 86), it must know that the caller is a teacher.

The concept​

Think of a visitor's badge. The front desk checks who you are once, then gives you a badge with your name, your role, and an expiry time. Each room only looks at the badge, and a badge without the school's stamp is worthless.

Example: authentication and authorization​

AuthenticationAuthorization
QuestionWho are you?Are you allowed to do this?
SMS example"This is anitha.rao""Anitha may take attendance for her sections"
MiddlewareUseAuthenticationUseAuthorization
Failure401 Unauthorized: no valid token403 Forbidden: valid token, not allowed

This lesson builds authentication, plus one rule of authorization: "you must be signed in". Lesson 86 adds roles.

Example: a hashed password​

A hash turns a password into a fixed string that cannot be turned back. To check a password, you hash what the user typed and compare. A salt is random data mixed in first, so two users with the same password get different hashes.

ASP.NET Core Identity's PasswordHasher does this with PBKDF2: SHA-512, a 16-byte salt, and 100,000 rounds. The rounds make every guess slow for an attacker who steals the database.

Example: a JWT​

A JWT (JSON Web Token) has three parts separated by dots: a header, a payload of claims (facts about the user), and a signature. Here is Anitha's token, decoded:

Output
header {"alg":"HS256","typ":"JWT"}
payload {"aud":"SchoolApp.Clients","iss":"SchoolApp.Api","exp":1791462371,"iat":1791458771,"nbf":1791458771,
"sub":"3","name":"anitha.rao","jti":"56177156-...","role":"Teacher","teacher_id":"1"}
signature 43 characters

The header and payload are only base64 encoded, not encrypted: anyone can read them. Never put a password or a secret in a claim. What nobody can do is change them, because the signature would no longer match.

Apply it to the SMS​

Step 1: passwords for the sample users​

The Users table (Lesson 14) has a PasswordHash column, filled with NOT-SET. The hash must be made in C#, with the same hasher the API uses, so a small tool sets it:

Output
dotnet run tools/set-password.cs -- admin "Demo@2026"
Password set for admin (hash starts AQAAAAIAAYag..., 84 characters).
dotnet run tools/set-password.cs -- nobody "Demo@2026"
No user named nobody.

All five sample users now have the demo password Demo@2026. It is only for your own local database.

Step 2: the signing key is a secret​

src/SchoolApp.Api/Options/JwtOptions.cs
using System.ComponentModel.DataAnnotations;

namespace SchoolApp.Api.Options;

// Lesson 85: the "Jwt" section. Issuer and Audience are in appsettings.json; SigningKey is a secret
// (user secrets while developing, an environment variable or a vault in production), never in the repo.
public class JwtOptions
{
public const string SectionName = "Jwt";

[Required]
public string Issuer { get; set; } = "";

[Required]
public string Audience { get; set; } = "";

// HMAC-SHA256 needs a key of at least 256 bits: 32 bytes. Shorter keys make the app refuse to start.
[Required, MinLength(32)]
public string SigningKey { get; set; } = "";

[Range(5, 1440)]
public int AccessTokenMinutes { get; set; } = 60;
}

Issuer, Audience, and AccessTokenMinutes go in appsettings.json. The signing key never goes in the repository: whoever has it can create a token for any user. It lives in user secrets (Lesson 70):

Terminal
dotnet user-secrets set "Jwt:SigningKey" "<at least 32 random characters>" --project src/SchoolApp.Api

ValidateOnStart checks it when the API starts. We tried both mistakes:

Output
(no key) DataAnnotation validation failed for 'JwtOptions' members: 'SigningKey' with the error:
'The SigningKey field is required.'.
(13 characters) ... 'The field SigningKey must be a string or array type with a minimum length of '32'.'.

Step 3: hashing and checking passwords​

Core defines IPasswordHasher; the API implements it with Identity's hasher:

src/SchoolApp.Api/Security/IdentityPasswordHasher.cs
using Microsoft.AspNetCore.Identity;
using SchoolApp.Core.Interfaces;

namespace SchoolApp.Api.Security;

// Lesson 85: passwords hashed with ASP.NET Core Identity's PasswordHasher: PBKDF2 with SHA-512, a random
// salt per password, and 100,000 rounds. The stored text holds the salt and the settings, so it can be
// checked later even if the defaults change.
public class IdentityPasswordHasher : IPasswordHasher
{
private static readonly PasswordHasher<object> Hasher = new PasswordHasher<object>();
private static readonly object User = new object(); // this hasher does not use the user object

// A real hash of a random password, used when the user does not exist, so that case costs the same time.
private static readonly Lazy<string> DummyHash = new Lazy<string>(() => Hasher.HashPassword(User, Guid.NewGuid().ToString()));

public string Hash(string password) => Hasher.HashPassword(User, password);

public bool Verify(string? hash, string password)
{
try
{
PasswordVerificationResult result = Hasher.VerifyHashedPassword(User, hash ?? DummyHash.Value, password);
return hash is not null && result != PasswordVerificationResult.Failed;
}
catch (FormatException)
{
return false; // not a hash at all, such as the seed value "NOT-SET"
}
}
}

Verify returns false for NOT-SET instead of crashing: it is not valid base64, so the hasher throws a FormatException.

Step 4: one answer for every failed sign-in​

src/SchoolApp.Core/Services/AuthService.cs
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Exceptions;
using SchoolApp.Core.Interfaces;

namespace SchoolApp.Core.Services;

// Lesson 85: signing in. One rule for every failure: "User name or password is wrong." (50060), whether the
// user does not exist, is inactive, has no password yet, or typed the wrong one. A different message for each
// would tell an attacker which user names are real.
public class AuthService
{
private readonly IUserRepository _users;
private readonly IPasswordHasher _hasher;
private readonly ITokenService _tokens;

public AuthService(IUserRepository users, IPasswordHasher hasher, ITokenService tokens)
{
_users = users;
_hasher = hasher;
_tokens = tokens;
}

public async Task<LoginResult> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default)
{
UserLogin? user = await _users.FindForLoginAsync(request.UserName.Trim(), cancellationToken);

// Verify even when there is no user (hash null), so both cases take the same time.
bool passwordMatches = _hasher.Verify(user?.PasswordHash, request.Password);
if (user is null || !user.IsActive || !passwordMatches)
{
throw new SchoolRuleException(50060, "User name or password is wrong.");
}

await _users.RecordLoginAsync(user.Id, cancellationToken);
(string token, DateTimeOffset expiresAt) = _tokens.CreateToken(user);
return new LoginResult(token, "Bearer", expiresAt, user.UserName, user.Roles);
}
}

Two security details:

  • One message. "User name or password is wrong." for every failure. Separate messages would tell an attacker which user names exist.
  • The same work. When the user does not exist, Verify still hashes against a dummy hash. Without it, "no such user" would answer faster, and the timing would give the secret away.

Step 5: creating the token​

src/SchoolApp.Api/Security/JwtTokenService.cs
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using SchoolApp.Api.Options;
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Interfaces;

namespace SchoolApp.Api.Security;

// Lesson 85: creates the signed access token (a JWT) for a user who has just signed in.
// The token carries claims: who the user is and which roles they have. Anyone can read them;
// nobody can change them without the signing key, because the signature would no longer match.
public class JwtTokenService : ITokenService
{
public const string TeacherIdClaim = "teacher_id";
public const string GuardianIdClaim = "guardian_id";

private readonly JwtOptions _options;
private readonly TimeProvider _time;

public JwtTokenService(IOptions<JwtOptions> options, TimeProvider time)
{
_options = options.Value;
_time = time;
}

public (string Token, DateTimeOffset ExpiresAt) CreateToken(UserLogin user)
{
DateTimeOffset now = _time.GetUtcNow();
DateTimeOffset expiresAt = now.AddMinutes(_options.AccessTokenMinutes);

List<Claim> claims =
[
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Name, user.UserName),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) // a unique id for this token
];
claims.AddRange(user.Roles.Select(role => new Claim("role", role)));
if (user.TeacherId is int teacherId)
{
claims.Add(new Claim(TeacherIdClaim, teacherId.ToString()));
}
if (user.GuardianId is int guardianId)
{
claims.Add(new Claim(GuardianIdClaim, guardianId.ToString()));
}

SecurityTokenDescriptor descriptor = new SecurityTokenDescriptor
{
Issuer = _options.Issuer,
Audience = _options.Audience,
Subject = new ClaimsIdentity(claims),
IssuedAt = now.UtcDateTime,
NotBefore = now.UtcDateTime,
Expires = expiresAt.UtcDateTime,
SigningCredentials = new SigningCredentials(SigningKey(_options), SecurityAlgorithms.HmacSha256)
};
return (new JsonWebTokenHandler().CreateToken(descriptor), expiresAt);
}

// The same key signs tokens here and checks them in Program.cs.
public static SymmetricSecurityKey SigningKey(JwtOptions options) => new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SigningKey));
}

sub is the user's Id, name the user name, and there is one role claim per role. jti is a unique id for this token. The time comes from TimeProvider, so a test can create a token in the past.

Step 6: checking tokens and protecting everything​

Program.cs (the Lesson 85 lines)
// Authentication: read "Authorization: Bearer <token>" and check the token on every request.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer();
builder.Services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
.Configure<IOptions<JwtOptions>>((bearer, jwt) =>
{
bearer.MapInboundClaims = false; // keep claim names as written: "sub", "name", "role"
bearer.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = jwt.Value.Issuer,
ValidAudience = jwt.Value.Audience,
IssuerSigningKey = JwtTokenService.SigningKey(jwt.Value),
NameClaimType = "name",
RoleClaimType = "role",
ClockSkew = TimeSpan.FromSeconds(30) // the default is 5 minutes of grace after expiry
};
});

// Authorization: every endpoint needs a signed-in user unless it says [AllowAnonymous].
builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());

app.UseAuthentication(); // Lesson 85: who is calling? (reads and checks the token)
app.UseAuthorization(); // are they allowed? (the fallback policy, and [Authorize] later)

The fallback policy applies to every endpoint that says nothing about authorization. That makes the API secure by default: an endpoint someone forgets to protect is still protected. Only three are marked [AllowAnonymous]: POST /api/auth/login, GET /api/school, and the OpenAPI pages while developing.

The empty 401 and 403 answers get the SMS response shape from the status code page (Lesson 80), now with their own messages.

Step 7: the controller​

Show src/SchoolApp.Api/Controllers/AuthController.cs
src/SchoolApp.Api/Controllers/AuthController.cs
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SchoolApp.Api.Models;
using SchoolApp.Api.Security;
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Services;

namespace SchoolApp.Api.Controllers;

// Lesson 85: signing in, and "who am I?".
[ApiController]
[Route("api/auth")]
[Produces("application/json")]
[ProducesErrorResponseType(typeof(ApiResponse))]
public class AuthController : ControllerBase
{
private readonly AuthService _auth;

public AuthController(AuthService auth)
{
_auth = auth;
}

/// <summary>Signs in with a user name and password, and returns an access token.</summary>
/// <response code="200">Signed in. Send the token as "Authorization: Bearer {token}" on every request.</response>
/// <response code="400">The request is invalid.</response>
/// <response code="401">The user name or password is wrong (50060).</response>
[HttpPost("login")]
[AllowAnonymous] // nobody has a token before signing in
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<LoginResult>> Login(LoginRequest request, CancellationToken cancellationToken)
{
LoginResult result = await _auth.LoginAsync(request, cancellationToken);
return ApiResponse.Ok(result, $"Signed in as {result.UserName}.");
}

/// <summary>Who the access token belongs to, read from its claims.</summary>
/// <response code="200">The signed-in user.</response>
/// <response code="401">No token, or the token is invalid or expired.</response>
[HttpGet("me")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public ApiResponse<CurrentUserDto> Me()
{
// ASP.NET Core has already checked the token and built User from its claims.
string? expires = User.FindFirstValue("exp");
CurrentUserDto me = new CurrentUserDto(
int.Parse(User.FindFirstValue("sub")!),
User.Identity!.Name!,
User.FindAll("role").Select(c => c.Value).ToList(),
ParseOrNull(User.FindFirstValue(JwtTokenService.TeacherIdClaim)),
ParseOrNull(User.FindFirstValue(JwtTokenService.GuardianIdClaim)),
expires is null ? null : DateTimeOffset.FromUnixTimeSeconds(long.Parse(expires)));
return ApiResponse.Ok(me, $"Signed in as {me.UserName}.");
}

private static int? ParseOrNull(string? value) => int.TryParse(value, out int number) ? number : null;
}

GET /api/auth/me never touches the database. By the time it runs, ASP.NET Core has checked the token and built User from its claims.

Step 8: sign in, then call the API​

Output
GET /api/students/ADM-2017-0003 (no token) 401
WWW-Authenticate: Bearer
{"status":"error","message":"Sign in first: send a valid access token.","traceId":"00-5378…"}

POST /api/auth/login {"userName":"anitha.rao","password":"Demo@2026"} 200
{"status":"success","message":"Signed in as anitha.rao.","data":{"accessToken":"eyJhbGciOiJIUzI1NiIs...","tokenType":"Bearer","expiresAt":"2026-10-08T12:26:10.935314+00:00","userName":"anitha.rao","roles":["Teacher"]}}

GET /api/students/ADM-2017-0003 Authorization: Bearer <token> 200
{"status":"success","message":"Student found.","data":{"id":3,"admissionNumber":"ADM-2017-0003","fullName":"Kabir Singh", ...}}

GET /api/auth/me Authorization: Bearer <token> 200
{"status":"success","message":"Signed in as anitha.rao.","data":{"userId":3,"userName":"anitha.rao","roles":["Teacher"],"teacherId":1,"guardianId":null,"tokenExpiresAt":"2026-10-08T12:26:11+00:00"}}

Step 9: everything that should fail​

RequestStatusWhat came back
Token with its last character changed401WWW-Authenticate: Bearer error="invalid_token", error_description="The signature key was not found"
Wrong password for anitha.rao401, 50060User name or password is wrong.
User name "nobody"401, 50060User name or password is wrong.
Empty body400The Password field is required. The UserName field is required.
GET /api/studnets without a token401Sign in first: send a valid access token.

The two failed sign-ins also took the same time: five tries each averaged about 0.16 seconds for the wrong password and 0.17 seconds for the unknown user.

The last row surprised us. In Lesson 80, an unknown route answered 404, but without a token it is now 401.

The fallback policy also covers requests that match no endpoint, so a stranger cannot even learn which routes exist. With a token, the same request still answers 404.

Step 10: tests that sign in​

Every API test now needs a token. ApiFactory creates its own user, test.admin, before the first API test and removes it after the last. It also gives the test run a signing key of its own.

Show tests/SchoolApp.Tests/Api/AuthTests.cs
tests/SchoolApp.Tests/Api/AuthTests.cs
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using SchoolApp.Tests.Fakes;

namespace SchoolApp.Tests.Api;

// Lesson 85: signing in, and what happens without a valid token.
[Collection("Api")]
public class AuthTests
{
private readonly ApiFactory _factory;

public AuthTests(ApiFactory factory)
{
_factory = factory;
}

[Fact]
public async Task Login_RightPassword_GivesAToken_ThatOpensProtectedEndpoints()
{
HttpClient client = _factory.CreateClient();
string token = await ApiFactory.GetTokenAsync(client);

client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
HttpResponseMessage me = await client.GetAsync("/api/auth/me");

Assert.Equal(HttpStatusCode.OK, me.StatusCode);
JsonElement data = (await me.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");
Assert.Equal(ApiFactory.UserName, data.GetProperty("userName").GetString());
Assert.Equal("Admin", data.GetProperty("roles")[0].GetString());
}

[Theory]
[InlineData(ApiFactory.UserName, "wrong-password")]
[InlineData("nobody", "wrong-password")]
public async Task Login_WrongPasswordOrUnknownUser_SameAnswer401(string userName, string password)
{
HttpResponseMessage response = await _factory.CreateClient()
.PostAsJsonAsync("/api/auth/login", new { userName, password });

Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
JsonElement body = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("User name or password is wrong.", body.GetProperty("message").GetString());
Assert.Equal(50060, body.GetProperty("errorCode").GetInt32());
}

[Theory]
[InlineData(null)] // no Authorization header
[InlineData("not-a-real-token")] // not a JWT at all
public async Task ProtectedEndpoint_WithoutAValidToken_Returns401(string? token)
{
HttpClient client = _factory.CreateClient();
if (token is not null)
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}

HttpResponseMessage response = await client.GetAsync("/api/students/ADM-2017-0003");

Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
JsonElement body = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("error", body.GetProperty("status").GetString());
}

// A token issued two hours ago has expired (tokens last 60 minutes).
[Fact]
public async Task ExpiredToken_Returns401()
{
HttpClient pastClient = _factory.WithWebHostBuilder(builder => builder.ConfigureTestServices(services =>
services.AddSingleton<TimeProvider>(new FixedTimeProvider(DateTimeOffset.UtcNow.AddHours(-2))))).CreateClient();
string oldToken = await ApiFactory.GetTokenAsync(pastClient);

HttpClient client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", oldToken);
HttpResponseMessage response = await client.GetAsync("/api/auth/me");

Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Contains("expired", response.Headers.WwwAuthenticate.ToString());
}

[Fact]
public async Task PublicEndpoint_NeedsNoToken()
{
HttpResponseMessage response = await _factory.CreateClient().GetAsync("/api/school");

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

// The fallback policy also covers requests that match no endpoint: a stranger cannot even learn
// which routes exist. A signed-in user gets the usual 404.
[Fact]
public async Task UnknownRoute_Is401WithoutToken_And404WithOne()
{
HttpResponseMessage anonymous = await _factory.CreateClient().GetAsync("/api/studnets");
HttpResponseMessage signedIn = await (await _factory.CreateSignedInClientAsync()).GetAsync("/api/studnets");

Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, signedIn.StatusCode);
}
}

ExpiredToken_Returns401 replaces TimeProvider with a clock two hours in the past. The token it gets has already expired, and the API refuses it with error="invalid_token" and "expired" in the header. The suite has 92 tests, all passing.

Common mistakes​

  1. Storing passwords, or fast hashes such as SHA-256 alone. A stolen table gives every password away. Fix: a slow, salted hash such as PBKDF2.
  2. The signing key in appsettings.json. Anyone with the repository can create admin tokens. Fix: user secrets, environment variables, or a vault.
  3. Secrets inside the token. The payload is readable by anyone. Fix: only ids, names, and roles.
  4. Protecting endpoints one by one. The one you forget is open. Fix: a fallback policy, with [AllowAnonymous] only where needed.
  5. Different messages for "no such user" and "wrong password". Attackers learn which accounts exist. Fix: one message, and the same amount of work.

In real projects​

From 16 years of .NET projects

A school portal shipped with sign-in on the main pages only. Months later, a parent found that the "download report card" link worked without signing in, for any student number.

Each new endpoint had needed its own [Authorize], and that one had been forgotten. Nothing in the tests called it without a token.

We switched to a fallback policy so every endpoint was protected unless marked otherwise. We also added a test that calls every endpoint without a token and expects 401.

Best practices​

  • Hash passwords with a slow, salted algorithm, and never log them.
  • Keep the signing key out of source control, and check its length at startup.
  • Keep access tokens short-lived; 60 minutes here.
  • Protect every endpoint by default with a fallback policy.
  • Use one error message for every failed sign-in.

Recap​

  • Users sign in once with POST /api/auth/login and get a JWT access token.
  • Every request sends Authorization: Bearer <token>; the API checks its signature, issuer, audience, and expiry.
  • Passwords are stored as PBKDF2 hashes; the tool tools/set-password.cs sets them.
  • The fallback policy protects every endpoint unless it is marked [AllowAnonymous].
  • 401 means "who are you?"; 403 (Lesson 86) means "not allowed".

Homework​

Library module task: Write a single-file library API where anyone may list books, but only a signed-in librarian may issue one. Keep one librarian in memory with a hashed password, add POST /api/login that returns a JWT, and protect everything else by default. Read the signing key from Library__SigningKey.

A correct result looks like:

Output
(no Library__SigningKey) Set Library__SigningKey to at least 32 characters.
GET /api/books 200 ["Wings of Fire","The Jungle Book","Malgudi Days"]
POST /api/book-issues (no token) 401
POST /api/login (wrong password) 401 {"message":"User name or password is wrong."}
POST /api/book-issues (librarian's token) 200 {"message":"9780143039655 issued to student 6 by librarian."}
Show the solution
library-assignment/085-library-sign-in.cs
// Library homework, Lesson 85: only a signed-in librarian may issue books. One librarian account in memory
// (password hashed at startup), a login endpoint that returns a JWT, and every other endpoint protected by
// default. The signing key comes from the environment variable Library__SigningKey.
// Run with: $env:Library__SigningKey = "<at least 32 characters>"; dotnet run library-assignment/085-library-sign-in.cs
#:sdk Microsoft.NET.Sdk.Web
#:package Microsoft.AspNetCore.Authentication.JwtBearer@10.0.11
#:package Microsoft.Extensions.Identity.Core@10.0.11
#:property PublishAot=false
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
string signingKey = builder.Configuration["Library:SigningKey"] ?? "";
if (signingKey.Length < 32)
{
throw new InvalidOperationException("Set Library__SigningKey to at least 32 characters.");
}
SymmetricSecurityKey key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));

builder.Services.AddControllers();
builder.Services.AddSingleton(key);
builder.Services.AddSingleton<Librarians>();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "Library",
ValidAudience = "Library",
IssuerSigningKey = key,
NameClaimType = "name"
};
});
builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());

WebApplication app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run("http://localhost:5085");

public record LoginRequest(string UserName, string Password);
public record IssueRequest(string Isbn, int StudentId);

// One librarian. The password is hashed once at startup; only the hash is kept.
public class Librarians
{
private readonly PasswordHasher<string> _hasher = new PasswordHasher<string>();
private readonly string _hash;

public Librarians()
{
_hash = _hasher.HashPassword("librarian", "Library@2026");
}

public bool Check(string userName, string password) =>
userName == "librarian" && _hasher.VerifyHashedPassword(userName, _hash, password) != PasswordVerificationResult.Failed;
}

[ApiController]
[Route("api")]
public class LibraryController : ControllerBase
{
private readonly Librarians _librarians;
private readonly SymmetricSecurityKey _key;

public LibraryController(Librarians librarians, SymmetricSecurityKey key)
{
_librarians = librarians;
_key = key;
}

[HttpPost("login")]
[AllowAnonymous]
public IActionResult Login(LoginRequest request)
{
if (!_librarians.Check(request.UserName, request.Password))
{
return Unauthorized(new { message = "User name or password is wrong." });
}
string token = new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{
Issuer = "Library",
Audience = "Library",
Subject = new ClaimsIdentity([new Claim("name", request.UserName)]),
Expires = DateTime.UtcNow.AddMinutes(30),
SigningCredentials = new SigningCredentials(_key, SecurityAlgorithms.HmacSha256)
});
return Ok(new { accessToken = token });
}

// Anyone may look at the catalogue.
[HttpGet("books")]
[AllowAnonymous]
public IActionResult Books() => Ok(new[] { "Wings of Fire", "The Jungle Book", "Malgudi Days" });

// Only a signed-in librarian may issue a book (the fallback policy protects it).
[HttpPost("book-issues")]
public IActionResult Issue(IssueRequest request) =>
Ok(new { message = $"{request.Isbn} issued to student {request.StudentId} by {User.Identity!.Name}." });
}

The librarian's password is hashed once when the app starts; only the hash stays in memory.

Quiz​

  1. What is the difference between 401 and 403?

    1. They mean the same
    2. 401: no valid token, so the API does not know who you are; 403: signed in, but not allowed
    3. 403 is for wrong passwords
    4. 401 is for servers
    Show answer

    Answer: B. 401: no valid token, so the API does not know who you are; 403: signed in, but not allowed Authentication failed versus authorization failed.

  2. Can anyone read the claims inside a JWT?

    1. No, they are encrypted
    2. Yes: they are only encoded, so never put secrets in them
    3. Only the server
    4. Only with the password
    Show answer

    Answer: B. Yes: they are only encoded, so never put secrets in them The signature stops changes, not reading.

  3. Why does sign-in give the same message for an unknown user and a wrong password?

    1. To save code
    2. Different messages would tell an attacker which user names exist
    3. The database cannot tell them apart
    4. HTTP requires it
    Show answer

    Answer: B. Different messages would tell an attacker which user names exist The SMS also does the same hashing work in both cases.

  4. What does the fallback authorization policy do?

    1. Signs users in
    2. Requires a signed-in user on every endpoint that does not say otherwise
    3. Retries failed requests
    4. Creates tokens
    Show answer

    Answer: B. Requires a signed-in user on every endpoint that does not say otherwise Forgotten endpoints stay protected.

  5. Where does the SMS keep the JWT signing key while developing?

    1. appsettings.json
    2. User secrets, outside the repository
    3. Inside the token
    4. In the database
    Show answer

    Answer: B. User secrets, outside the repository Whoever has the key can create a token for any user.

Interview questions​

Questions are grouped by experience level. Click a question to see a model answer. All questions: Security interview questions.

Fresher (0-2 yrs)​

F What is the difference between authentication and authorization, and between 401 and 403? concept

Authentication answers "who are you?"; authorization answers "are you allowed to do this?". A request without a valid token fails authentication and gets 401 Unauthorized. A signed-in user who may not do something fails authorization and gets 403 Forbidden.

See the example · Permalink

F What are the three parts of a JWT, and can anyone read its contents? concept

A header (the algorithm, such as HS256), a payload of claims (such as sub, name, role, exp), and a signature, separated by dots. The header and payload are only base64url encoded, so anyone can read them; the signature only stops anyone changing them. Never put passwords or secrets in claims.

See the example · Permalink

F How should an API store passwords? concept

As a slow, salted hash, never as text or with a fast hash alone. The SMS uses ASP.NET Core Identity's PasswordHasher: PBKDF2 with SHA-512, a random salt for each password, and 100,000 rounds. To sign in, the typed password is hashed the same way and compared.

See the example · Permalink

Mid (3-5 yrs)​

M What does a fallback authorization policy do, and why use one? concept

It applies to every endpoint that has no authorization attributes of its own. Setting it to RequireAuthenticatedUser makes the API secure by default: a new endpoint that someone forgets to protect still needs a signed-in user. Public endpoints opt out with [AllowAnonymous], such as login.

See the example · Permalink

M Which token settings does the API check on every request, and why set ClockSkew? coding

The signature (with the signing key), the issuer, the audience, and the expiry time (exp and nbf). ClockSkew is a grace period for clocks that differ between servers; its default is 5 minutes, so an expired token would still work for 5 more minutes. The SMS sets it to 30 seconds.

See the example · Permalink

M Where should a JWT signing key be kept, and what happens if it leaks? concept

Outside source control: user secrets while developing, and environment variables or a key vault in production. Anyone with the key can create a valid token for any user, including an admin. If it leaks, replace it at once; every token signed with the old key then stops working. The SMS also refuses to start without a key of at least 32 characters.

See the example · Permalink

Senior (6-9 yrs)​

S A security review finds that your login says 'unknown user' for one mistake and 'wrong password' for another. Why is that a problem, and how do you fix it? scenario

It tells an attacker which user names exist, so they can focus password guessing on real accounts. Use one message for every failure ("User name or password is wrong."), and make both paths do the same work: the SMS still verifies against a dummy hash when the user does not exist, so the response time does not give it away either. Add rate limiting and lockout against guessing.

How to answer: Explain user enumeration through messages and through timing, then the fix for each, and the extra defences against password guessing.

See the example · Permalink

S A parent reports that a report-card link works without signing in. How did it happen, and how do you stop it happening again? scenario

That endpoint was protected one by one with [Authorize], and someone forgot it. Fix it now, then change the design: a fallback policy that requires a signed-in user everywhere, with [AllowAnonymous] only where needed. Add an automated test that calls every endpoint without a token and expects 401, and check the logs for anyone who used the open link.

How to answer: Cover the immediate fix, the design change that makes the mistake impossible, the test that guards it, and the investigation of possible data exposure.

See the example · Permalink

Architect / Lead (10+ yrs)​

A Access tokens last 60 minutes. How would you let users stay signed in longer, and sign them out at once when needed? design

Keep access tokens short and add refresh tokens: long-lived, random, stored hashed on the server with the user and device, and exchanged for a new access token (rotating the refresh token each time). Signing out, or blocking a user, revokes their refresh tokens; for instant cut-off, keep a short deny list of token ids (jti) until they expire, or shorten access token lifetimes. Larger systems often move sign-in to an identity provider using OpenID Connect.

How to answer: Separate access and refresh tokens, explain storage and rotation, cover revocation and its delay, and say when a dedicated identity provider is worth it.

See the example · Permalink

Resources​

  • Code for this lesson: git tag lesson-85-jwt-sign-in (code repository coming soon)
  • Files: src/SchoolApp.Core/Services/AuthService.cs, src/SchoolApp.Api/Security/JwtTokenService.cs, src/SchoolApp.Api/Security/IdentityPasswordHasher.cs, src/SchoolApp.Api/Controllers/AuthController.cs, tools/set-password.cs, tests/SchoolApp.Tests/Api/AuthTests.cs, library-assignment/085-library-sign-in.cs
  • Video: coming soon

What's next​

The API now knows who is calling. Next, it decides what each role may do: a teacher takes attendance, the accountant records payments, and a parent sees only their own children. Lesson 86: Roles and policies

Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.