Building a REST API with .NET — Part 4: Authentication and Authorization
Series Overview
This is a 6-part series on building a production-ready REST API with .NET:
- Project Setup, Contracts, and Controllers — Solution structure, contracts, repository pattern, controllers, and mapping
- Database Integration with Dapper — PostgreSQL with Docker, Dapper ORM, migrations, and slugs
- Business Logic and Validation — Service layer, FluentValidation, middleware, and cancellation tokens
- Authentication and Authorization (this article) — JWT tokens, claims-based authorization, and user identity
- Filtering, Sorting, and Pagination — Query parameters, dynamic sorting, paginated responses
- Production Readiness — Versioning, Swagger/OpenAPI, health checks, caching, and API key auth
Introduction
In Part 3 we added validation and a service layer. But right now, anyone can create, update, or delete movies. We need to control who can access what. In this part we add JWT-based authentication (proving who you are) and claims-based authorization (controlling what you can do).
JWT Concepts
A JSON Web Token (JWT) is a compact, URL-safe way to represent claims between two parties. It consists of three parts, separated by dots:
1
header.payload.signature
Header
Describes the token type and signing algorithm:
1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}
Payload
Contains claims — statements about the user:
1
2
3
4
5
6
{
"sub": "user-id-123",
"name": "Bengt",
"admin": "true",
"exp": 1694476800
}
Claims can be standard (like sub for subject, exp for expiration) or custom (like admin).
Signature
Created by combining the encoded header and payload with a secret key. This proves the token has not been tampered with:
1
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
Bearer Scheme
JWTs are sent in the Authorization header using the Bearer scheme:
1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
The server validates the signature, checks expiration, and extracts claims — all without hitting a database.
Adding JWT Authentication
Install the Package
1
dotnet add Movies.API package Microsoft.AspNetCore.Authentication.JwtBearer
Configuration
Add JWT settings to appsettings.json:
1
2
3
4
5
6
7
{
"Jwt": {
"Secret": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
"Issuer": "https://movies.api",
"Audience": "https://movies.api"
}
}
Important: In production, store the secret in a vault (Azure Key Vault, AWS Secrets Manager) or environment variables — never in source control.
Configure Authentication in Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
// Add authentication
builder.Services.AddAuthentication(x =>
{
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(x =>
{
x.TokenValidationParameters = new TokenValidationParameters
{
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!)),
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
ValidateIssuer = true,
ValidateAudience = true
};
});
Each validation parameter serves a purpose:
| Parameter | Purpose |
|---|---|
ValidateIssuerSigningKey | Verifies the token was signed with our secret |
ValidateLifetime | Rejects expired tokens |
ValidateIssuer | Ensures the token was issued by our API |
ValidateAudience | Ensures the token is intended for our API |
Add Middleware
Order matters — authentication must come before authorization:
1
2
3
4
5
6
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Protect the Controller
Add [Authorize] at the controller level to require authentication for all endpoints, and [AllowAnonymous] on endpoints that should be publicly accessible:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
[ApiController]
[Authorize]
public class MoviesController : ControllerBase
{
private readonly IMovieService _movieService;
public MoviesController(IMovieService movieService)
{
_movieService = movieService;
}
[AllowAnonymous]
[HttpGet(ApiEndpoints.Movies.Get)]
public async Task<IActionResult> Get([FromRoute] string identity,
CancellationToken token)
{
var movie = Guid.TryParse(identity, out var id)
? await _movieService.GetByIdAsync(id, token)
: await _movieService.GetBySlugAsync(identity, token);
if (movie is null)
return NotFound();
return Ok(movie.ToMovieResponse());
}
[AllowAnonymous]
[HttpGet(ApiEndpoints.Movies.GetAll)]
public async Task<IActionResult> GetAll(CancellationToken token)
{
var movies = await _movieService.GetAllAsync(token);
return Ok(movies.ToMoviesResponse());
}
// Create, Update, Delete remain protected by [Authorize]
// ...
}
With this setup:
- GET endpoints are public — anyone can browse movies
- POST, PUT, DELETE require a valid JWT — only authenticated users can modify data
If an unauthenticated request hits a protected endpoint, ASP.NET Core returns 401 Unauthorized.
Claims-Based Authorization
Authentication tells us who the user is. Authorization tells us what they can do. We use claims-based policies for fine-grained access control.
Define Authorization Policies
In Program.cs, configure policies after authentication:
1
2
3
4
5
builder.Services.AddAuthorization(x =>
{
x.AddPolicy("Admin", policy =>
policy.RequireClaim("admin", "true"));
});
This creates an “Admin” policy that requires the JWT to contain an admin claim with the value "true".
Apply the Policy
Restrict write operations to admins:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
[Authorize("Admin")]
[HttpPost(ApiEndpoints.Movies.Create)]
public async Task<IActionResult> Create([FromBody] CreateMovieRequest request,
CancellationToken token)
{
var movie = request.ToMovie();
await _movieService.CreateAsync(movie, token);
var response = movie.ToMovieResponse();
return CreatedAtAction(nameof(Get), new { identity = movie.Id }, response);
}
[Authorize("Admin")]
[HttpPut(ApiEndpoints.Movies.Update)]
public async Task<IActionResult> Update([FromRoute] Guid id,
[FromBody] UpdateMovieRequest request, CancellationToken token)
{
var movie = request.ToMovie(id);
var updatedMovie = await _movieService.UpdateAsync(movie, token);
if (updatedMovie is null)
return NotFound();
return Ok(updatedMovie.ToMovieResponse());
}
[Authorize("Admin")]
[HttpDelete(ApiEndpoints.Movies.Delete)]
public async Task<IActionResult> Delete([FromRoute] Guid id,
CancellationToken token)
{
var deleted = await _movieService.DeleteAsync(id, token);
if (!deleted)
return NotFound();
return Ok();
}
Now:
- An authenticated user without the
adminclaim gets 403 Forbidden on write endpoints - An unauthenticated user gets 401 Unauthorized
- Anyone can read movies
Extracting User Identity
For user-specific features — like movie ratings — we need to know which user is making the request. The user’s identity is encoded in the JWT claims.
Getting the User ID from Claims
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[HttpPut("api/movies/{id:guid}/rating")]
[Authorize]
public async Task<IActionResult> RateMovie([FromRoute] Guid id,
[FromBody] RateMovieRequest request, CancellationToken token)
{
var userId = HttpContext.User.Claims
.SingleOrDefault(x => x.Type == "userid");
if (userId is null)
return Unauthorized();
// Pass userId.Value to the service
var result = await _ratingService.RateMovieAsync(
id, Guid.Parse(userId.Value), request.Rating, token);
if (!result)
return NotFound();
return Ok();
}
The HttpContext.User property is populated automatically by the JWT middleware. You can extract any claim from the token payload.
Passing User Context to the Service Layer
For a cleaner approach, create an extension method or use IHttpContextAccessor:
1
2
3
4
5
6
7
8
9
10
11
// Extension method approach
public static Guid? GetUserId(this HttpContext context)
{
var userId = context.User.Claims
.SingleOrDefault(x => x.Type == "userid");
if (userId is null)
return null;
return Guid.Parse(userId.Value);
}
Usage:
1
var userId = HttpContext.GetUserId();
Generating Tokens for Testing
For testing, you will need a way to generate JWTs. A minimal token generator:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Identity.API/Controllers/TokenController.cs (separate project)
[HttpPost("token")]
public IActionResult GenerateToken([FromBody] TokenRequest request)
{
// In production, validate credentials against a user store
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new(JwtRegisteredClaimNames.Sub, request.UserId),
new(JwtRegisteredClaimNames.Email, request.Email),
new("userid", request.UserId)
};
if (request.IsAdmin)
{
claims.Add(new Claim("admin", "true"));
}
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddHours(8),
Issuer = _config["Jwt:Issuer"],
Audience = _config["Jwt:Audience"],
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256Signature)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return Ok(new { Token = tokenHandler.WriteToken(token) });
}
Tip: In a real application, authentication would be handled by a dedicated identity service (e.g., IdentityServer, Auth0, Azure AD B2C). The Movies API only needs to validate tokens, not issue them.
Testing with the HTTP File
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
### Get a token (from your identity service)
POST https://localhost:5002/token
Content-Type: application/json
{
"userId": "d8a1e7c0-4f3b-4e5a-9c2d-1a2b3c4d5e6f",
"email": "admin@example.com",
"isAdmin": true
}
### Create a movie (requires admin token)
POST https://localhost:5001/api/movies
Content-Type: application/json
Authorization: Bearer
{
"title": "Inception",
"year": 2010,
"genre": ["Action", "Sci-Fi", "Thriller"]
}
### Get all movies (no auth required)
GET https://localhost:5001/api/movies
Summary
We have added a complete authentication and authorization layer:
- JWT authentication validates tokens without database lookups
[Authorize]and[AllowAnonymous]control access at the endpoint level- Claims-based policies provide fine-grained authorization (admin vs regular user)
- User identity extraction enables user-specific features
The key insight is the separation: authentication (proving identity) and authorization (granting permissions) are distinct concerns, and ASP.NET Core’s middleware pipeline handles them elegantly.
What’s Next?
In Part 5: Filtering, Sorting, and Pagination, we will add query parameters for filtering movies by title and year, dynamic sorting by any field, and paginated responses with metadata.