mailwal

ASP.NET Core API Security: Stop Confusing Authentication and Authorization

DotNetMirror’s Newsletter

ASP.NET Core API Security: Stop Confusing Authentication and Authorization

Master the differences between AuthN and AuthZ, fix middleware ordering bugs, and implement secure cookie-based authentication in ASP.NET Core Web APIs.

Welcome back to DotNetMirror!

Have you ever spent hours debugging an API, staring at a 401 Unauthorized or a 403 Forbidden error, only to realize you put one line of middleware in the wrong order? Or maybe you’ve inherited a massive codebase where security checks are buried inside messy if-else statements?

Today, we are tearing down the confusion between Authentication and Authorization in ASP.NET Core Web API. We will cover exactly how they differ, how the .NET pipeline processes them, and how to implement highly secure, cookie-based authentication for your Single Page Applications (SPAs).

The Conceptual Foundation: The Airport Analogy

If you ever get these two concepts mixed up, just think about an airport:

  • Authentication (AuthN) is Passport Control. The officer looks at your passport to prove you are exactly who you say you are. This process answers the question: “Who are you?”

  • Authorization (AuthZ) is the Boarding Gate. Your passport doesn’t give you the right to board a specific airplane. The gate agent scans your ticket to ensure you are allowed on Flight 402, and whether you belong in Economy or First Class. This process answers: “What are you allowed to do?”

The golden rule of API security: You cannot authorize access without first having a verified identity. Passport control always comes before the boarding gate.

HTTP Status Codes: 401 vs. 403

When these layers fail, ASP.NET Core returns specific HTTP status codes.

  • 401 Unauthorized (AuthN Failure): The framework throws this when credentials (like a cookie or JWT) are missing, tampered with, or expired.

  • 403 Forbidden (AuthZ Failure): This happens when the API recognizes exactly who you are, but you simply don’t meet the criteria (roles or claims) to hit that specific endpoint.

Architecture Tip: Never prompt a user to re-authenticate on a 403. Logging in again won’t fix a 403; the user fundamentally lacks the required permissions.

The #1 Developer Pitfall: Middleware Ordering

In ASP.NET Core, the request lifecycle must flow in a strictly defined order. The most common security bug is placing authorization before authentication.

C#

// CORRECT ORDER
app.UseRouting();

app.UseAuthentication(); // 1. Passport Control (Who are you?)
app.UseAuthorization();  // 2. Boarding Gate (What can you do?)

app.MapControllers();

If you reverse these, your authorization checks will run against a blank, unauthenticated context, resulting in endless 401 errors or completely bypassed security.

Implementing Secure Cookie Authentication for APIs

While JWTs are popular, they are often stored in browser Local Storage, leaving SPAs (like React, Angular, or Blazor) vulnerable to Cross-Site Scripting (XSS) attacks. Instead, we can use HTTP-Only Cookies, which the browser handles securely without exposing the token to JavaScript.

Because this is a Web API and not an MVC application, we must override the default cookie behavior to prevent the framework from returning 302 Redirects to a login page, and instead return proper 401 and 403 status codes.

Here is the configuration for Program.cs:

C#

using Microsoft.AspNetCore.Authentication.Cookies;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

// 1. Add Cookie Authentication
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        // Prevent redirects for API endpoints
        options.Events.OnRedirectToLogin = context =>
        {
            context.Response.StatusCode = 401;
            return Task.CompletedTask;
        };
        options.Events.OnRedirectToAccessDenied = context =>
        {
            context.Response.StatusCode = 403;
            return Task.CompletedTask;
        };

        // Security Tweaks
        options.Cookie.HttpOnly = true; // Protects against XSS
        options.Cookie.SameSite = SameSiteMode.Strict; // Protects against CSRF
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always; // Requires HTTPS
        
        options.ExpireTimeSpan = TimeSpan.FromMinutes(20);
    });

// 2. Add Authorization Services
builder.Services.AddAuthorization();

var app = builder.Build();

app.UseHttpsRedirection();

// Strict Pipeline Order
app.UseAuthentication(); 
app.UseAuthorization();

app.MapControllers();
app.Run();

The Authentication Controller

Now, let’s create the endpoints to issue the secure cookie, test our authorization, and log out.

C#

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace AuthWebApi.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class AuthController : ControllerBase
    {
        // 1. LOGIN ENDPOINT
        [HttpPost("login")]
        public async Task<IActionResult> Login([FromBody] LoginRequest request)
        {
            // HARDCODED FOR DEMO ONLY. In production, check a hashed password in a database.
            if (request.Username == "admin" && request.Password == "password123")
            {
                var claims = new List<Claim>
                {
                    new Claim(ClaimTypes.Name, request.Username),
                    new Claim(ClaimTypes.Role, "Admin") 
                };

                var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);

                // Issues the encrypted cookie to the response stream headers automatically
                await HttpContext.SignInAsync(
                    CookieAuthenticationDefaults.AuthenticationScheme, 
                    new ClaimsPrincipal(claimsIdentity));

                return Ok(new { Message = "Logged in successfully. Cookie has been set." });
            }

            return Unauthorized("Invalid credentials.");
        }

        // 2. SECURE ENDPOINT (Requires 'Admin' Role)
        [HttpGet("secure-data")]
        [Authorize(Roles = "Admin")]
        public IActionResult GetSecureData()
        {
            return Ok(new { Message = "Hello Admin! You accessed this data securely via standard session cookies." });
        }

        // 3. LOGOUT ENDPOINT
        [HttpPost("logout")]
        [Authorize]
        public async Task<IActionResult> Logout()
        {
            // Destroys the cookie session
            await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
            return Ok(new { Message = "Logged out successfully. Cookie has been cleared." });
        }
    }

    public record LoginRequest(string Username, string Password);
}

Best Practices to Remember

  • Keep checks at the boundary: Don’t write if (user.Role == "Admin") deep inside your business logic. Use policies at the controller level.

  • Enforce Secure-by-Default: Configure a FallbackPolicy that requires an authenticated user globally so you don’t accidentally expose an endpoint.

  • Always use HTTPS: Cookies (and JWTs) can be intercepted over unencrypted HTTP.

Watch the Full Video Tutorial

If you want to see this code in action, including how to test these secure cookies using Bruno (an open-source Postman alternative) and the Edge browser, check out the full step-by-step video on the DotNetMirror YouTube channel:

🎥 Watch the full tutorial on YouTube

💻 Grab the source code from GitHub here https://github.com/dotnetmirror?tab=repositories

Subscribe to our YouTube channel - https://www.youtube.com/dotnetmirror

© 2026 DotNetMirror
[email protected], Hyderabad, India