Protecting Endpoints — [Authorize] and Role-Based Access

📋 Table of Contents
  1. Protecting Endpoints
  2. Common Mistakes

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.");
}
Note: When you configure a global 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].
Tip: For Angular CORS support, configure the CORS policy to allow the 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.
Warning: [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.

🧠 Test Yourself

An Angular client sends POST /api/posts with a valid JWT but without an Authorization header because CORS blocked it in pre-flight. What does the API return?