Skip to main content
Published / updated

Authentication, Authorization, CORS and Swagger

Before you start

You need: error handling (Article 10).

Time: about 55 minutes, plus the practice. This is the security article of the track.

Learning objective

Secure an API so that no request can read or modify data belonging to another tenant, and document it so a client developer can use it without asking questions.

Topics

  • Authentication versus authorization
  • JWT bearer authentication
  • Issuing a token
  • Claims and multi-tenancy
  • Roles and policies
  • Password hashing
  • CORS
  • Swagger with authentication
  • Security checklist

Authentication versus authorization

Authentication establishes identity: who are you. Authorization decides access: may you do this.

app.UseAuthentication(); // reads the token, populates HttpContext.User
app.UseAuthorization(); // checks [Authorize] against that User

Order is not optional. UseAuthorization reads HttpContext.User, which UseAuthentication populates. Reversed, the user is always anonymous and every [Authorize] endpoint returns 401 — reported as "my valid token is being rejected".

Both must sit after UseRouting, so the selected endpoint's [Authorize] metadata is available.

ResponseMeaning
401 UnauthorizedNot authenticated — no token, expired, or invalid
403 ForbiddenAuthenticated, not permitted — wrong role

JWT bearer authentication

Right-click the project → Manage NuGet Packages → Browse, search for the package, and click Install.

The Package Manager Console (Tools → NuGet Package Manager → Package Manager Console) does the same thing typed:

Install-Package Microsoft.AspNetCore.Authentication.JwtBearer
var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
?? throw new InvalidOperationException("Jwt configuration section is missing.");

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtOptions.Issuer,

ValidateAudience = true,
ValidAudience = jwtOptions.Audience,

ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtOptions.Key)),

ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
};
});

builder.Services.AddAuthorization();

Every Validate* flag must be true. Turning one off to make development easier removes a real control:

Flag offConsequence
ValidateIssuerSigningKeyAny token is accepted, including one an attacker signed
ValidateLifetimeExpired tokens work forever
ValidateIssuerA token from a different system is accepted
ValidateAudienceA token issued for another API is accepted

ClockSkew defaults to five minutes, so an "expired" token keeps working for five minutes after its exp. Reduce it to 30 seconds; zero causes spurious failures when server clocks differ slightly.

A JWT has three parts: header, payload and signature. The payload is Base64, not encrypted — anyone with the token can read every claim. Never put a secret in it.

Issuing a token

public sealed class TokenService : ITokenService
{
private readonly JwtOptions _options;

public TokenService(IOptions<JwtOptions> options)
{
_options = options.Value;
}

public TokenResult CreateToken(User user)
{
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.PublicId.ToString()),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new(ClaimTypes.Name, user.Name),
new("SchoolId", user.SchoolId.ToString()),
new(ClaimTypes.Role, user.Role.ToString())
};

var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var expires = DateTime.UtcNow.AddMinutes(_options.ExpiryMinutes);

var token = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
notBefore: DateTime.UtcNow,
expires: expires,
signingCredentials: credentials);

return new TokenResult
{
AccessToken = new JwtSecurityTokenHandler().WriteToken(token),
ExpiresAt = expires
};
}
}

The signing key must be at least 32 bytes for HMAC-SHA256, must come from configuration, and must never be in appsettings.json. Validate it at startup:

builder.Services
.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection(JwtOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();

Short expiry — 15 to 60 minutes — with a refresh token for longer sessions. A JWT cannot be revoked before it expires, which is exactly why it should not live for days.

Claims and multi-tenancy

public static class ClaimsPrincipalExtensions
{
public static int GetSchoolId(this ClaimsPrincipal user)
{
var claim = user.FindFirst("SchoolId")
?? throw new InvalidOperationException("The SchoolId claim is missing.");

return int.Parse(claim.Value);
}

public static Guid GetUserPublicId(this ClaimsPrincipal user)
{
return Guid.Parse(user.FindFirst(JwtRegisteredClaimNames.Sub)!.Value);
}
}
[HttpGet]
public async Task<ActionResult<PagedResult<StudentDto>>> Search(
[FromQuery] StudentQueryParameters query, CancellationToken ct)
{
return Ok(await _studentService.SearchAsync(User.GetSchoolId(), query, ct));
}

SchoolId comes from the token and is applied in every query's WHERE clause. A schoolId query parameter is a request from the client to choose which tenant's data to read, and no amount of validation makes that safe.

The token is signed, so the claim cannot be tampered with — that signature is the whole basis of trusting it.

Roles and policies

[Authorize] // any authenticated user
[Authorize(Roles = "Admin")] // a specific role
[Authorize(Roles = "Admin,Principal")] // any of these
[AllowAnonymous] // opt out
[ApiController]
[Route("api/students")]
[Authorize]
public class StudentsController : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Search(...) { } // any authenticated user

[HttpPost]
[Authorize(Roles = "Admin,Principal")]
public async Task<IActionResult> Create(...) { }

[HttpDelete("{publicId:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(...) { }
}

Policies express more than a role name:

builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanManageStudents", policy =>
policy.RequireRole("Admin", "Principal"));

options.AddPolicy("CanCollectFees", policy =>
policy.RequireRole("Admin", "Staff")
.RequireClaim("SchoolId"));

options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
[Authorize(Policy = "CanManageStudents")]

FallbackPolicy is the setting worth adopting. It makes every endpoint require authentication by default, so a new controller added without [Authorize] is protected rather than public. Anonymous endpoints then opt out explicitly with [AllowAnonymous].

Without it, one forgotten attribute is an open endpoint — and nothing fails or warns.

Resource-based authorization

Role checks answer "may this user create students". They do not answer "may this user edit this student".

var student = await _studentService.GetByPublicIdAsync(User.GetSchoolId(), publicId, ct);

if (student is null)
{
return NotFound();
}

The tenant filter in the query is the resource check. A student belonging to another school simply is not found — which returns 404 rather than 403, and so reveals nothing about whether the record exists.

Password hashing

public sealed class PasswordHasher : IPasswordHasher
{
public string Hash(string password)
{
return BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12);
}

public bool Verify(string password, string hash)
{
return BCrypt.Net.BCrypt.Verify(password, hash);
}
}

Never store a password. Never encrypt one — encryption is reversible. Use BCrypt, Argon2, or ASP.NET Core Identity's hasher.

Never MD5 or a bare SHA-256: they are fast by design, which is exactly wrong for passwords. A modern GPU tests billions of SHA-256 candidates per second. BCrypt is deliberately slow and salts each hash automatically.

[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<TokenResult>> Login(LoginRequest request, CancellationToken ct)
{
var user = await _userService.FindByEmailAsync(request.Email, ct);

if (user is null || !_passwordHasher.Verify(request.Password, user.PasswordHash))
{
_logger.LogWarning("Failed login attempt for {Email}", request.Email);

return Unauthorized(new ProblemDetails
{
Title = "Invalid credentials",
Status = StatusCodes.Status401Unauthorized
});
}

if (!user.IsActive)
{
return Unauthorized(new ProblemDetails { Title = "Invalid credentials" });
}

await _userService.RecordLoginAsync(user.Id, ct);

return Ok(_tokenService.CreateToken(user));
}

Three details:

  • The same message for an unknown email and a wrong password. "No such user" tells an attacker which addresses are registered.
  • The same message for an inactive account. Same reason.
  • The email is logged; the password is not. A logged password is a breach in a log file, and a failed attempt frequently contains a real password mistyped into the wrong field.

Rate-limit the endpoint:

builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("login", limiter =>
{
limiter.PermitLimit = 5;
limiter.Window = TimeSpan.FromMinutes(1);
});
});
[EnableRateLimiting("login")]

Without it, an unlimited-rate login endpoint is a credential-stuffing target.

CORS

The browser's same-origin policy blocks JavaScript from reading a response from a different origin — scheme, host and port — unless the server permits it.

builder.Services.AddCors(options =>
{
options.AddPolicy("SchoolPortal", policy =>
{
policy.WithOrigins(
"https://portal.nexcoding.in",
"http://localhost:4200")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
app.UseRouting();
app.UseCors("SchoolPortal");
app.UseAuthentication();
app.UseAuthorization();

Position is exact: after UseRouting, before UseAuthentication.

Before routing, endpoint-specific policies do not apply. After authentication, the browser's preflight OPTIONS request — which carries no token — is rejected with 401 before CORS can answer it, and the console shows a CORS error whose real cause is the ordering.

// Never
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod();

That opens the API to every site on the internet. It is also incompatible with AllowCredentials() — the framework throws at startup rather than let you do both, which is the one place the framework saves you.

Read origins from configuration so the list differs per environment:

policy.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get<string[]>()!);

CORS is fixed on the server, always

Access to fetch at 'https://api.nexcoding.in/api/students'
from origin 'https://portal.nexcoding.in' has been blocked by CORS policy.

No frontend change resolves this. No fetch option, no header the client can add.

The request usually reached the server and succeeded — the server returned 200, and the browser then refused to hand the response to JavaScript. That is why the same call works in Postman, which is not a browser and does not enforce CORS.

An OPTIONS request returning 404 or 405 in the Network tab means the CORS middleware is not wired up at all.

Security headers

app.Use(async (context, next) =>
{
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
context.Response.Headers.Append("X-Frame-Options", "DENY");
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
context.Response.Headers.Append("Permissions-Policy", "geolocation=(), camera=(), microphone=()");

await next(context);
});

app.UseHsts();
app.UseHttpsRedirection();
HeaderPrevents
X-Content-Type-Options: nosniffMIME-type sniffing turning an upload into a script
X-Frame-Options: DENYClickjacking via an iframe
Referrer-PolicyLeaking full URLs to third parties
Strict-Transport-SecurityDowngrade to HTTP

Swagger with authentication

Install-Package Swashbuckle.AspNetCore
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "NexCoding School Portal API",
Version = "v1",
Description = "Student, exam and fee management for NexCoding Academy."
});

options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "Paste the token only — Swagger adds the 'Bearer ' prefix."
});

options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});

var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFile));
});
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>

Without AddSecurityDefinition, Swagger UI has no Authorize button and every secured endpoint returns 401 — which people report as a broken API.

/// <summary>Creates a student.</summary>
/// <response code="201">The student was created.</response>
/// <response code="400">Validation failed.</response>
/// <response code="409">The roll number is already in use.</response>
[HttpPost]
[ProducesResponseType(typeof(StudentDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<StudentDto>> Create(...) { }

ProducesResponseType is what makes generated client code and the documented contract correct. Without it, every response is documented as 200 with an unknown body.

if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

Swagger UI in production is a published map of your API. If it must be available, require authorization:

app.MapSwagger().RequireAuthorization("Monitoring");

Security checklist

CheckRequirement
PasswordsBCrypt or Argon2, never MD5, SHA-256 alone or encryption
Token expiryShort, with refresh
Signing key32+ bytes, from configuration, validated at startup
Token validationEvery Validate* flag true, small ClockSkew
Tenant idFrom the claim, in every query's WHERE
Default authorizationFallbackPolicy requiring authentication
Anonymous endpointsExplicit [AllowAnonymous]
Login responsesIdentical for unknown user and wrong password
Login rate limitingEnabled
CORSSpecific origins, never AllowAnyOrigin
Middleware orderRouting → CORS → Authentication → Authorization
SQLParameterised, always
Request modelsDTOs, never entities
Response modelsDTOs, never entities
ErrorsProblemDetails, no stack traces in production
LogsNo passwords, tokens or connection strings
TransportHTTPS with HSTS
SecretsUser secrets or a vault, never appsettings.json
SwaggerDevelopment only, or authorized

Errors you will hit

What you seeCauseFix
401 with a token that looks fineexp, iss or aud mismatchDecode it at jwt.io and compare
401 on every requestUseAuthorization before UseAuthenticationSwap them
CORS error only on authenticated callsUseCors after UseAuthenticationMove it
AllowAnyOrigin with AllowCredentials failsThe browser forbids the combinationName the origins
Works in Postman, fails in the browserCORS — Postman does not enforce itFix on the server
A Teacher reaches an Admin endpointNo [Authorize(Roles=...)]Add it; hiding the button is not security

Postman not enforcing CORS is the signature of a CORS problem, not evidence the API is fine.

Common mistakes

  • UseAuthorization before UseAuthentication
  • UseCors before UseRouting or after UseAuthentication
  • AllowAnyOrigin() to make CORS go away
  • Trying to fix CORS in the frontend
  • A Validate* flag turned off for convenience
  • The default five-minute ClockSkew left in place
  • A signing key in appsettings.json
  • A secret placed in the token payload
  • No FallbackPolicy, so a forgotten [Authorize] leaves an endpoint public
  • SchoolId taken from the request
  • Different login messages for unknown user and wrong password
  • Logging the attempted password
  • No rate limiting on login
  • Swagger with no security definition
  • Swagger UI exposed in production
  • Long-lived tokens with no refresh

Practice

The course exercises are add JWT basics and document an API.

  1. Add JWT bearer authentication with every validation flag on and ClockSkew at 30 seconds.
  2. Build a login endpoint issuing a token with SchoolId and role claims.
  3. Decode the token at jwt.io. Confirm every claim is readable — then explain why no secret belongs there.
  4. Call a secured endpoint with no token, an expired token, and a token signed with a different key. Record each result.
  5. Set ValidateIssuerSigningKey = false. Sign a token yourself with any key and confirm it is accepted. Turn it back on.
  6. Leave ClockSkew at its default. Expire a token and confirm it still works for five minutes.
  7. Swap UseAuthentication and UseAuthorization. Call a secured endpoint with a valid token and record the 401.
  8. Add role-based authorization. Call an Admin endpoint with a Teacher token and confirm 403, not 401.
  9. Add FallbackPolicy. Create a new controller with no [Authorize] and confirm it is protected.
  10. Hash a password with BCrypt. Hash the same password twice and confirm the hashes differ — then confirm both verify.
  11. Return "user not found" for an unknown email and "wrong password" otherwise. Explain what that leaks, then unify the messages.
  12. Add rate limiting to login. Send six requests in a minute and confirm the 429.
  13. Configure CORS for a specific origin. Call the API from a page on another port and confirm the block. Find the OPTIONS preflight in the Network tab.
  14. Move UseCors after UseAuthentication and confirm the preflight now fails with 401.
  15. Try AllowAnyOrigin() together with AllowCredentials(). Record the startup exception.
  16. Configure Swagger with a security definition. Authorize in the UI and call a secured endpoint.
  17. Add ProducesResponseType to every action and confirm the documented responses.

Exercises 5, 7 and 14 are the three that most often make a secured API insecure or unusable.

You can now

  • Secure an API with JWT bearer authentication
  • Order CORS, authentication and authorisation correctly
  • Decode a token and diagnose a 401 from its claims
  • Enforce roles on the server, not in the UI
  • Say why Postman working proves nothing about CORS

Review questions

  1. Why must UseAuthentication come before UseAuthorization?
  2. What happens with ValidateIssuerSigningKey = false?
  3. Why can CORS not be fixed in the frontend, and why does Postman not show the problem?
  4. Why must login return the same message for an unknown email and a wrong password?

Next: Guided API project