Web Security & Ethical Hacking · Lesson 12 of 23

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 alg header in the JWT is explicitly validated against an allowlist — the API does NOT accept alg: 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.

C#
// 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?

C#
// 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.

C#
// 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.

C#
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.

C#
// 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.

C#
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.

C#
// 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).

C#
// 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.

C#
// 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.

Bash
# Scan git history for secrets
trufflehog git file://. --only-verified

10.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.