Securing Your ASP.NET Core Web API

1 min read

Your Interview Guide to Securing ASP.NET Core APIs

Building an API is easy. Building a secure API is what gets you hired. In an interview, you must be able to clearly distinguish between the two pillars of API security: Authentication and Authorization.


1. Authentication ('AuthN'): 'Who are you?'

This is the process of proving a user is who they claim to be. The user presents credentials (like a username/password), and the server validates them.

  • In ASP.NET Core: This is handled by Authentication middleware.
  • Common Mechanism: For stateless Web APIs, the standard is JSON Web Tokens (JWT). The user logs in once, gets a token, and passes that token in the Authorization header of every subsequent request.
  • Key Attribute: [AllowAnonymous]. This attribute skips authentication. Use it for your /login and /register endpoints.

2. Authorization ('AuthZ'): 'What are you allowed to do?'

This process happens after authentication. Once we know who the user is, we check if they have permission to access a specific resource.

  • In ASP.NET Core: This is handled by Authorization middleware.
  • Key Attribute: [Authorize]. This is the most important attribute. By default, it simply checks 'Is this user logged in?'.
  • Advanced Authorization:
    • Role-Based: [Authorize(Roles = "Admin")]
    • Policy-Based: [Authorize(Policy = "MustBeOver21")]. This is the modern, flexible way to handle complex business rules.

This cluster will dive into the practical implementation of these concepts.

How to Implement JWT (JSON Web Token) Authentication

Interview Question: 'How do you implement JWTs in ASP.NET Core?'

Answer: 'It's a two-part process. First, you configure the authentication middleware in Program.cs to validate incoming tokens. Second, you create a /login endpoint that creates and issues tokens to valid users.'


Part 1: Configuring the Middleware (Validating Tokens)

You need to tell .NET how to read and validate the tokens. You specify the secret key (Key), who the Issuer is, and who the Audience is.

// In Program.cs

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration;

// 1. Add the Authentication services
builder.Services.AddAuthentication(options => {
  options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
  options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options => {
  // 2. Configure how to validate the token
  options.TokenValidationParameters = new TokenValidationParameters {
    ValidateIssuer = true,
    ValidateAudience = true,
    ValidateLifetime = true, // Check if it's expired
    ValidateIssuerSigningKey = true,
    ValidIssuer = config["Jwt:Issuer"],
    ValidAudience = config["Jwt:Audience"],
    IssuerSigningKey = new SymmetricSecurityKey(
      Encoding.UTF8.GetBytes(config["Jwt:Key"])
    )
  };
});

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

var app = builder.Build();

// 3. Add the middleware to the pipeline (in the right order!)
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

Part 2: Creating the Endpoint (Issuing Tokens)

You need a controller (e.g., AuthController) with a login endpoint. This endpoint takes a username/password, validates them, and if they are correct, builds and returns a token.

[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase {
  private readonly IConfiguration _config;

  public AuthController(IConfiguration config) {
    _config = config;
  }

  [AllowAnonymous] // Allow users to hit this endpoint without a token
  [HttpPost("login")]
  public IActionResult Login([FromBody] LoginModel login) {
    // 1. Authenticate the user (e.g., check against database)
    // THIS IS A FAKE CHECK! Use a real one.
    if (login.Username != "test" || login.Password != "password") {
      return Unauthorized("Invalid credentials");
    }

    // 2. If valid, create the token's claims (data)
    var claims = new[] {
      new Claim(JwtRegisteredClaimNames.Sub, login.Username),
      new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
      new Claim(ClaimTypes.Role, "User") // Add roles
    };

    // 3. Get the secret key
    var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
    var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

    // 4. Create the token object
    var token = new JwtSecurityToken(
      issuer: _config["Jwt:Issuer"],
      audience: _config["Jwt:Audience"],
      claims: claims,
      expires: DateTime.Now.AddMinutes(30),
      signingCredentials: creds
    );

    // 5. Serialize the token to a string and return it
    return Ok(new {
      token = new JwtSecurityTokenHandler().WriteToken(token)
    });
  }
}

Authentication vs. Authorization Explained (AuthN vs. AuthZ)

Interview Question: 'What's the difference between Authentication and Authorization?'

This is a fundamental security question. A simple, clear analogy is the best way to answer.

The simple answer: 'Authentication is proving who you are. Authorization is proving what you're allowed to do.'


The Airport Analogy

  • Authentication (AuthN): You show your Passport / Driver's License at the security checkpoint. The agent confirms your identity. You are now 'authenticated'.
  • Authorization (AuthZ): You show your Boarding Pass at the gate. The agent checks if you are 'authorized' to get on this specific flight (e.g., Flight 123 to London). Your passport alone doesn't let you on the plane.

Authentication always comes before authorization.


How it looks in ASP.NET Core:

We use attributes on our controller actions to define the rules.

[ApiController]
[Route("api/[controller]")]
public class DataController : ControllerBase {

  // --- AUTHENTICATION --- 

  [HttpGet("public")]
  [AllowAnonymous] // 1. NO authentication needed. Everyone can access.
  public IActionResult GetPublicData() {
    return Ok("This is public data.");
  }

  [HttpGet("private")]
  [Authorize] // 2. Authentication IS needed.
  // This checks: 'Is the user logged in?' (i.e., do they have a valid token?)
  // It does not check who they are, just that they are someone.
  public IActionResult GetPrivateData() {
    return Ok("You are authenticated! This is private data.");
  }

  // --- AUTHORIZATION --- 

  [HttpGet("admin")]
  [Authorize(Roles = "Admin")] // 3. Authorization IS needed.
  // This checks: '1. Is the user logged in?' (AuthN)
  // '2. Do they have a 'Admin' role claim in their token?' (AuthZ)
  public IActionResult GetAdminData() {
    return Ok("Welcome, Admin! This is admin-only data.");
  }

  [HttpGet("manager")]
  [Authorize(Roles = "Manager,Admin")] // Can be comma-separated
  public IActionResult GetManagerData() {
    return Ok("Welcome, Manager or Admin!");
  }

  [HttpGet("invoices")]
  [Authorize(Policy = "CanManageInvoices")] // 4. Policy-Based (AuthZ)
  // This is the most flexible. It runs custom C# logic
  // (e.g., 'Is the user an Admin OR are they in the 'Finance' department?')
  public IActionResult GetInvoices() {
    return Ok("You have invoice management permissions.");
  }
}

Building RESTful APIs: Verbs, Status Codes, and Best Practices

Interview Question: 'What makes an API 'RESTful'?'

Answer: 'REST (Representational State Transfer) is an architectural style for APIs. It's not a strict protocol, but a set of 'best practices' that make APIs predictable and easy to use. The key principles are using HTTP verbs correctly and using HTTP status codes to indicate the outcome.'


1. Use HTTP Verbs (Methods) Correctly

A URL should represent a noun (a resource), not a verb (an action).

  • Good: /users/123
  • Bad: /getUserById?id=123

The HTTP verb is what defines the action:

VerbActionExample URLSQL Equivalent
GETRead a resource (or list)GET /users or GET /users/123SELECT
POSTCreate a new resourcePOST /usersINSERT
PUTUpdate/Replace an entire resourcePUT /users/123UPDATE
DELETEDelete a resourceDELETE /users/123DELETE
PATCHPartially update a resourcePATCH /users/123UPDATE

2. Use HTTP Status Codes Correctly

Don't just return 200 OK for everything. The status code is a critical part of the response.

  • 2xx (Success):
    • 200 OK: Standard success for GET, PUT, PATCH.
    • 201 Created: Used for POST. The response should include a Location header pointing to the new resource (e.g., Location: /users/124).
    • 204 No Content: Standard success for DELETE. You send no body back.
  • 4xx (Client Error):
    • 400 Bad Request: The request was invalid (e.g., failed validation, missing field).
    • 401 Unauthorized: Authentication failed (you're not logged in).
    • 403 Forbidden: Authentication succeeded, but authorization failed (you're logged in, but not an admin).
    • 404 Not Found: The resource (e.g., /users/999) doesn't exist.
  • 5xx (Server Error):
    • 500 Internal Server Error: Your code crashed. This should be caught by your exception handler and should never expose a stack trace.

Code Example: A RESTful Controller

Notice the use of [HttpGet], [HttpPost], and the IActionResult return types (Ok(), NotFound(), CreatedAtAction()).

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase {

  // GET /api/users/123
  [HttpGet("{id}")]
  public IActionResult GetUser(int id) {
    var user = _repository.GetUser(id);
    if (user == null) {
      return NotFound(); // 404 Not Found
    }
    return Ok(user); // 200 OK
  }

  // POST /api/users
  [HttpPost]
  public IActionResult CreateUser([FromBody] UserCreateDto newUser) {
    if (!ModelState.IsValid) {
      return BadRequest(ModelState); // 400 Bad Request
    }
    
    var createdUser = _repository.Create(newUser);
    
    // 201 Created. Returns a 'Location' header: /api/users/124
    return CreatedAtAction(
      nameof(GetUser), // The 'GET' action to find the new user
      new { id = createdUser.Id }, 
      createdUser
    );
  }

  // DELETE /api/users/123
  [HttpDelete("{id}")]
  public IActionResult DeleteUser(int id) {
    var user = _repository.GetUser(id);
    if (user == null) {
      return NotFound(); // 404
    }

    _repository.Delete(user);
    return NoContent(); // 204 No Content
  }
}

💬