With JWT Bearer authentication configured, protecting endpoints is straightforward — add [Authorize] to restrict access to authenticated users, add [Authorize(Roles = "Admin")] for role-based restrictions, and add [AllowAnonymous] for public endpoints. The middleware pipeline validates the Bearer token before the controller action runs. For resource-based authorisation (a user can only edit their own post), the controller checks the authenticated user’s ID against the resource’s owner ID.
Protecting Endpoints
// ── Global [Authorize] — require auth on ALL endpoints by default ──────────
builder.Services.AddControllers(options =>
options.Filters.Add(new AuthorizeFilter())); // all endpoints require auth
// ── Or apply [Authorize] per-controller ────────────────────────────────────
[ApiController]
[Route("api/posts")]
[Authorize] // all actions in this controller require auth
public class PostsController(IPostService service, IAuthorizationService authSvc) : ControllerBase
{
// ── Public endpoint — override controller-level [Authorize] ──────────
[HttpGet]
[AllowAnonymous] // anyone can list published posts
public async Task<ActionResult<PagedResult<PostSummaryDto>>> GetAll(
[FromQuery] int page = 1, [FromQuery] int size = 10, CancellationToken ct = default)
=> Ok(await service.GetPublishedAsync(page, size, ct));
// ── Any authenticated user ────────────────────────────────────────────
[HttpPost]
public async Task<ActionResult<PostDto>> Create(
CreatePostRequest request, CancellationToken ct)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
var post = await service.CreateAsync(request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = post.Id }, post);
}
// ── Role-based access ─────────────────────────────────────────────────
[HttpDelete("{id:int}")]
[Authorize(Roles = "Admin")] // only Admins can delete any post
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
// ── Resource-based access — user can edit their own post ──────────────
[HttpPut("{id:int}")]
public async Task<ActionResult<PostDto>> Update(
int id, UpdatePostRequest request, CancellationToken ct)
{
var post = await service.GetByIdAsync(id, ct);
if (post is null) return NotFound();
// Resource-based: Admin OR post's own author
var authResult = await authSvc.AuthorizeAsync(User, post, "CanEditPost");
if (!authResult.Succeeded) return Forbid();
var updated = await service.UpdateAsync(id, request, ct);
return updated is null ? NotFound() : Ok(updated);
}
}
// ── Reading claims from the token ─────────────────────────────────────────
string userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
string email = User.FindFirstValue(ClaimTypes.Email)!;
bool isAdmin = User.IsInRole("Admin");
string displayName = User.FindFirstValue("displayName")!;
// ── ClaimsPrincipal extension helper ─────────────────────────────────────
public static class ClaimsPrincipalExtensions
{
public static string GetUserId(this ClaimsPrincipal user)
=> user.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new UnauthorizedAccessException("User ID claim missing.");
}
AuthorizeFilter (all endpoints require auth by default), you must add [AllowAnonymous] to public endpoints like login, registration, and public content. This “secure by default” approach prevents accidentally leaving sensitive endpoints unauthenticated. It is more defensive than the alternative (no global filter, add [Authorize] to every secure endpoint) where a new endpoint is public until someone remembers to add [Authorize].Authorization header: opts.AllowAnyHeader() or explicitly opts.WithHeaders("Authorization", "Content-Type"). Without this, Angular’s pre-flight OPTIONS request for the Authorization header fails, and the actual GET/POST is never sent. Also configure .AllowCredentials() if you use cookies alongside JWT. CORS must be configured before UseAuthentication() in the middleware pipeline.[Authorize(Roles = "Admin,Editor")] means Admin OR Editor — the user needs to be in at least one of the listed roles. If you need AND (user must be both Admin AND Editor), stack separate attributes: [Authorize(Roles = "Admin")] [Authorize(Roles = "Editor")] — both must pass. Most role requirements are OR semantics; if you find yourself needing AND, consider whether you need a new combined role or a policy.Common Mistakes
Mistake 1 — Returning 404 instead of 403 for unauthorised resource access (information leakage)
❌ Consideration — returning 404 hides whether the resource exists but confuses legitimate authorised users.
✅ Decision — for truly sensitive resources (user B should not know user A’s private post exists), return 404. For resources known to exist (a public post that requires login to edit), return 403.
Mistake 2 — Reading user ID from request body instead of JWT claims (trust wrong source)
❌ Wrong — using request.UserId from the request body for resource ownership.
✅ Correct — always use User.FindFirstValue(ClaimTypes.NameIdentifier) from the JWT claims — the only trusted source of identity.