REST API Engineering · Lesson 13 of 19
API Security Checklist — 20 Controls for Production APIs
How to Use This Checklist
Walk through every section before your API goes to production. For each item, mark it PASS (control exists and is verified), FAIL (not implemented — must fix before launch), or N/A (not applicable with documented justification).
A FAIL on any Authentication, Authorization, or Transport item is a launch blocker. Other FAILs should be tracked as security debt with a remediation timeline.
Section 1 — Authentication
1.1 JWT Signature Is Validated on Every Request
Requirement: The API verifies the JWT signature on every authenticated request using the correct algorithm and key.
PASS criteria:
ValidateIssuerSigningKey = true- Signing key loaded from Key Vault / environment — never hardcoded
- The
algheader in the JWT is explicitly validated against an allowlist — the API does NOT acceptalg: none
FAIL if: The API trusts the alg field in the JWT header (algorithm confusion attack vector). The API uses ValidateIssuerSigningKey = false in any environment.
// Correct — explicit algorithm allowlist
options.TokenValidationParameters = new TokenValidationParameters
{
ValidAlgorithms = new[] { "RS256" }, // Explicit allowlist
ValidateIssuerSigningKey = true,
IssuerSigningKey = rsa256Key,
ValidateIssuer = true,
ValidIssuer = "https://login.microsoftonline.com/{tenant}/v2.0",
ValidateAudience = true,
ValidAudience = "api://myapp"
};1.2 Access Tokens Have Short Expiry
Requirement: Access token TTL is 15–60 minutes maximum.
PASS criteria: exp claim in JWT is <= 60 minutes from iat.
FAIL if: Tokens have multi-hour or multi-day TTL. Tokens have no expiry.
1.3 Refresh Token Rotation Is Implemented
Requirement: Each use of a refresh token issues a new refresh token and invalidates the old one.
PASS criteria: Old refresh token is revoked after use. Reuse of a revoked refresh token invalidates the entire session (reuse detection).
FAIL if: Refresh tokens are long-lived and reusable indefinitely.
1.4 No Sensitive Data in JWT Claims
Requirement: JWT payload contains only non-sensitive identity claims.
PASS criteria: No passwords, no SSNs, no full PII in JWT payload. User ID (opaque), roles, and tenant ID are acceptable.
FAIL if: Email address, full name, date of birth, or any regulated PII is in the JWT payload. Remember: JWTs are base64-encoded, not encrypted — anyone who receives the token can read the payload.
1.5 Credentials Not in URL Parameters
Requirement: API keys, tokens, and passwords are never passed in query strings.
PASS criteria: Auth credentials travel only in headers (Authorization: Bearer ...).
FAIL if: Any endpoint accepts ?token= or ?api_key= in the URL. URLs appear in access logs, browser history, and referrer headers — tokens in URLs leak.
Section 2 — Authorization
2.1 Every Endpoint Has Explicit Authorization
Requirement: No endpoint returns data or performs actions without checking that the caller is authorized.
PASS criteria: Every controller action or route handler has an [Authorize] attribute, authorization policy, or explicit Allow Anonymous with documented justification.
FAIL if: Any endpoint relies on "security by obscurity" — it's not documented, so who would find it?
// Correct — policy on every endpoint, explicit anonymous exemption
[Authorize(Policy = "RequireAuthenticatedUser")]
[ApiController]
public class OrdersController : ControllerBase
{
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id) { ... }
[AllowAnonymous] // Documented: public product catalog
[HttpGet("/api/products")]
public async Task<IActionResult> ListProducts() { ... }
}2.2 No IDOR — Resource-Level Authorization Checked
Requirement: The API verifies the authenticated user owns or has permission to access the specific resource, not just that they are authenticated.
PASS criteria: Every query filters by the caller's identity: WHERE UserId = @currentUserId.
FAIL if: The API returns any resource as long as the caller is authenticated. Changing /api/orders/123 to /api/orders/456 returns another user's data.
// Correct — resource ownership checked
var order = await _db.Orders
.Where(o => o.Id == id && o.UserId == currentUserId) // ownership check
.FirstOrDefaultAsync();
if (order == null) return NotFound(); // Same response for not-found and unauthorized (prevent enumeration)2.3 Role Claims Are Not Trusted From Client
Requirement: User roles and permissions are determined server-side from the identity store, not from claims the client submitted.
PASS criteria: Roles loaded from the database or identity provider after authentication. [Authorize(Roles = "Admin")] works against server-issued claims, not client-provided values.
FAIL if: The API reads a role value from a request body field or query parameter that the client controls.
2.4 Privilege Escalation Not Possible via API Calls
Requirement: A regular user cannot elevate themselves to admin by calling API endpoints.
PASS criteria: Admin role assignment requires an existing admin actor. No endpoint allows users to modify their own roles.
Section 3 — Input Validation
3.1 All Inputs Are Validated
Requirement: Every input — path parameters, query strings, headers, request body fields — is validated for type, format, length, and allowed values.
PASS criteria: Validation attributes on all DTOs. String length limits on all string fields. Enum/allowlist validation for constrained fields.
public class CreateOrderRequest
{
[Required]
[MaxLength(100)]
public string ProductName { get; set; } = "";
[Range(1, 10000)]
public decimal Amount { get; set; }
[RegularExpression(@"^[A-Z]{2,3}$")]
public string CurrencyCode { get; set; } = "";
}FAIL if: Any string field accepts unlimited length. Any numeric field accepts negative values where only positive are valid.
3.2 Parameterized Queries Everywhere
Requirement: No SQL string concatenation or interpolation with user input.
PASS criteria: All database queries use parameterized queries, stored procedures, or ORM (EF Core, Dapper with @param syntax).
FAIL if: Any code path constructs SQL strings by concatenating user-provided values.
// FAIL — SQL injection
var sql = $"SELECT * FROM Users WHERE Email = '{email}'";
// PASS — parameterized
var user = await _db.Users.Where(u => u.Email == email).FirstOrDefaultAsync();3.3 File Uploads Validated
Requirement: File uploads validate type, size, and content.
PASS criteria:
- MIME type validated against allowlist (not just file extension — check magic bytes)
- File size limit enforced (e.g., 10 MB max)
- Files stored outside web root — never in a directly accessible path
- File names sanitized — no
../../path traversal
FAIL if: The API accepts any file type. Files are stored using the original filename provided by the client.
3.4 Deserialization Is Safe
Requirement: JSON/XML deserialization does not allow polymorphic type instantiation from untrusted input.
PASS criteria: System.Text.Json is used (safe by default). If using Newtonsoft.Json, TypeNameHandling = TypeNameHandling.None.
Section 4 — Transport Security
4.1 HTTPS Only — HTTP Redirected or Rejected
Requirement: The API never serves responses over plain HTTP.
PASS criteria: HTTP requests redirected to HTTPS (301) or rejected (400). HTTPS enforced in production hosting configuration.
app.UseHttpsRedirection();
app.UseHsts();FAIL if: The API responds to HTTP requests with data. HTTP to HTTPS redirect returns a 200 with data on the HTTP request.
4.2 HSTS Enabled
Requirement: HTTP Strict Transport Security header tells browsers to only connect via HTTPS.
PASS criteria: Strict-Transport-Security: max-age=31536000; includeSubDomains on all responses.
FAIL if: HSTS header is absent. max-age is less than 1 year (31536000 seconds).
4.3 TLS 1.2 Minimum — TLS 1.0/1.1 Disabled
Requirement: Only TLS 1.2 and 1.3 are accepted.
PASS criteria: TLS 1.0 and 1.1 disabled at the load balancer / Azure App Service / Azure Front Door level.
FAIL if: The API accepts TLS 1.0 (vulnerable to POODLE, BEAST).
Section 5 — HTTP Security Headers
5.1 Required Security Headers Present
Check with curl -I https://yourapi.com/api/health or securityheaders.com.
| Header | Required Value | Purpose |
|--------|---------------|---------|
| Strict-Transport-Security | max-age=31536000; includeSubDomains | Force HTTPS |
| X-Content-Type-Options | nosniff | Prevent MIME sniffing |
| X-Frame-Options | DENY or SAMEORIGIN | Prevent clickjacking |
| Content-Security-Policy | Appropriate for your app | Control resource loading |
| Referrer-Policy | strict-origin-when-cross-origin | Limit referrer leakage |
| Permissions-Policy | Restrict unused APIs | Limit browser feature access |
FAIL if: Any of the first three headers are absent from API responses.
5.2 Server Version Not Leaked
Requirement: The Server and X-Powered-By headers must not disclose technology or version information.
PASS criteria: No Server: Microsoft-IIS/10.0 or X-Powered-By: ASP.NET in responses.
// Remove server header in ASP.NET Core
builder.WebHost.ConfigureKestrel(options =>
{
options.AddServerHeader = false;
});Section 6 — Rate Limiting
6.1 Rate Limiting Enforced at the API Layer
Requirement: Rate limiting is applied per IP address, per authenticated user, and for sensitive endpoints (login, password reset) with stricter limits.
PASS criteria: 429 Too Many Requests returned when limits exceeded. Retry-After header included. Rate limit state persists across instances (Redis, not in-memory).
// ASP.NET Core 7+ built-in rate limiting
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("api", o =>
{
o.PermitLimit = 100;
o.Window = TimeSpan.FromMinutes(1);
o.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
o.QueueLimit = 10;
});
options.AddFixedWindowLimiter("auth", o =>
{
o.PermitLimit = 5;
o.Window = TimeSpan.FromMinutes(15);
});
});FAIL if: No rate limiting exists. Rate limiting is enforced only at the WAF (application layer must also enforce — WAF bypass is common).
6.2 Account Lockout on Authentication Endpoints
Requirement: After N failed login attempts, the account is temporarily locked.
PASS criteria: Account locked after 5–10 failures. Lockout duration increases with repeated failures. Lockout event is logged and optionally triggers an alert.
Section 7 — Logging and Monitoring
7.1 All Authentication Events Are Logged
Requirement: Successful logins, failed logins, token refreshes, and logouts are logged with contextual information.
PASS criteria: Each auth event log includes: timestamp, user ID (or attempted identifier), IP address, user agent, result (success/failure), and correlation ID.
FAIL if: Failed login attempts are not logged. Auth logs do not include IP address.
7.2 No PII in Logs
Requirement: Log entries must not contain personally identifiable information.
PASS criteria: Passwords (obviously) not logged. Emails, phone numbers, SSNs, DOBs not logged in application logs. User ID (opaque identifier) acceptable; full name or email is not.
FAIL if: Any log entry contains a password, credit card number, or health record.
7.3 Request Logging Covers All Endpoints
Requirement: Every inbound request is logged with method, path, status code, duration, and authenticated identity.
PASS criteria: Structured log entry per request. Logs shipped to centralized log store (Azure Monitor, Elasticsearch). Retention policy meets compliance requirements (typically 90 days minimum, 1 year for regulated industries).
Section 8 — Error Handling
8.1 No Stack Traces in Production Responses
Requirement: Internal exception details are never returned to API clients in production.
PASS criteria: Generic error message returned (e.g., {"error": "An unexpected error occurred", "traceId": "abc123"}). Full exception logged server-side. traceId allows support to correlate the log.
FAIL if: The response body contains exception type names, file paths, line numbers, or SQL query text.
// Program.cs
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/error");
}
// ErrorController.cs
[Route("/error")]
public IActionResult Error()
{
var feature = HttpContext.Features.Get<IExceptionHandlerFeature>();
_logger.LogError(feature?.Error, "Unhandled exception");
return Problem(detail: null, title: "An unexpected error occurred");
}8.2 Consistent Error Format
Requirement: All error responses use the same structure so clients can parse them reliably.
PASS criteria: Error responses always return Content-Type: application/problem+json (RFC 7807). Validation errors return structured field-level errors.
Section 9 — Dependencies
9.1 No Packages with Known Critical CVEs
PASS criteria: dotnet list package --vulnerable returns no high/critical findings. npm audit returns 0 critical, 0 high.
FAIL if: Any critical CVE present in direct or transitive dependencies.
9.2 Lock Files Committed and Enforced
PASS criteria: package-lock.json or packages.lock.json committed to source control. CI fails on lock file mismatch (dotnet restore --locked-mode).
Section 10 — Secrets Management
10.1 No Hardcoded Secrets
Requirement: No passwords, API keys, connection strings, or certificates in source code.
PASS criteria: Scanning tool (truffleHog, git-secrets, GitHub secret scanning) runs on every commit. No secrets in appsettings.json, no password= in any committed file.
# Scan git history for secrets
trufflehog git file://. --only-verified10.2 Secrets Loaded from Vault at Runtime
PASS criteria: All secrets loaded from Azure Key Vault, AWS Secrets Manager, or equivalent. Managed identity used for Key Vault access — no Key Vault access key stored anywhere.
10.3 Secrets Are Rotated Regularly
PASS criteria: Database passwords and API keys are rotated at least annually, or automatically (Azure Key Vault rotation policies). Rotation process is documented and tested. Old secret is revoked after rotation.
FAIL if: Any secret has not been rotated in over 12 months. No rotation process exists.
Final Sign-off
Before launching, every FAIL must be resolved or explicitly accepted with documented risk justification, owner, and remediation date. Security sign-off is required from a security engineer or the tech lead responsible for the application's security posture.