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.
| Response | Meaning |
|---|---|
| 401 Unauthorized | Not authenticated — no token, expired, or invalid |
| 403 Forbidden | Authenticated, 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 off | Consequence |
|---|---|
ValidateIssuerSigningKey | Any token is accepted, including one an attacker signed |
ValidateLifetime | Expired tokens work forever |
ValidateIssuer | A token from a different system is accepted |
ValidateAudience | A 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();
| Header | Prevents |
|---|---|
X-Content-Type-Options: nosniff | MIME-type sniffing turning an upload into a script |
X-Frame-Options: DENY | Clickjacking via an iframe |
Referrer-Policy | Leaking full URLs to third parties |
Strict-Transport-Security | Downgrade 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
| Check | Requirement |
|---|---|
| Passwords | BCrypt or Argon2, never MD5, SHA-256 alone or encryption |
| Token expiry | Short, with refresh |
| Signing key | 32+ bytes, from configuration, validated at startup |
| Token validation | Every Validate* flag true, small ClockSkew |
| Tenant id | From the claim, in every query's WHERE |
| Default authorization | FallbackPolicy requiring authentication |
| Anonymous endpoints | Explicit [AllowAnonymous] |
| Login responses | Identical for unknown user and wrong password |
| Login rate limiting | Enabled |
| CORS | Specific origins, never AllowAnyOrigin |
| Middleware order | Routing → CORS → Authentication → Authorization |
| SQL | Parameterised, always |
| Request models | DTOs, never entities |
| Response models | DTOs, never entities |
| Errors | ProblemDetails, no stack traces in production |
| Logs | No passwords, tokens or connection strings |
| Transport | HTTPS with HSTS |
| Secrets | User secrets or a vault, never appsettings.json |
| Swagger | Development only, or authorized |
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
| 401 with a token that looks fine | exp, iss or aud mismatch | Decode it at jwt.io and compare |
| 401 on every request | UseAuthorization before UseAuthentication | Swap them |
| CORS error only on authenticated calls | UseCors after UseAuthentication | Move it |
AllowAnyOrigin with AllowCredentials fails | The browser forbids the combination | Name the origins |
| Works in Postman, fails in the browser | CORS — Postman does not enforce it | Fix on the server |
| A Teacher reaches an Admin endpoint | No [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
UseAuthorizationbeforeUseAuthenticationUseCorsbeforeUseRoutingor afterUseAuthenticationAllowAnyOrigin()to make CORS go away- Trying to fix CORS in the frontend
- A
Validate*flag turned off for convenience - The default five-minute
ClockSkewleft 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 SchoolIdtaken 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.
- Add JWT bearer authentication with every validation flag on and
ClockSkewat 30 seconds. - Build a login endpoint issuing a token with
SchoolIdand role claims. - Decode the token at jwt.io. Confirm every claim is readable — then explain why no secret belongs there.
- Call a secured endpoint with no token, an expired token, and a token signed with a different key. Record each result.
- Set
ValidateIssuerSigningKey = false. Sign a token yourself with any key and confirm it is accepted. Turn it back on. - Leave
ClockSkewat its default. Expire a token and confirm it still works for five minutes. - Swap
UseAuthenticationandUseAuthorization. Call a secured endpoint with a valid token and record the 401. - Add role-based authorization. Call an
Adminendpoint with aTeachertoken and confirm 403, not 401. - Add
FallbackPolicy. Create a new controller with no[Authorize]and confirm it is protected. - Hash a password with BCrypt. Hash the same password twice and confirm the hashes differ — then confirm both verify.
- Return "user not found" for an unknown email and "wrong password" otherwise. Explain what that leaks, then unify the messages.
- Add rate limiting to login. Send six requests in a minute and confirm the 429.
- Configure CORS for a specific origin. Call the API from a page on another port and confirm the block. Find the
OPTIONSpreflight in the Network tab. - Move
UseCorsafterUseAuthenticationand confirm the preflight now fails with 401. - Try
AllowAnyOrigin()together withAllowCredentials(). Record the startup exception. - Configure Swagger with a security definition. Authorize in the UI and call a secured endpoint.
- Add
ProducesResponseTypeto 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
- Why must
UseAuthenticationcome beforeUseAuthorization? - What happens with
ValidateIssuerSigningKey = false? - Why can CORS not be fixed in the frontend, and why does Postman not show the problem?
- Why must login return the same message for an unknown email and a wrong password?
Next: Guided API project