StackPractices
intermediate By Mathias Paulenko

API Security Checklist — Authentication to Encryption

A thorough security checklist for APIs: authentication, authorization, input validation, rate limiting, encryption, logging, and deployment hardening.

Introduction

Your API exposes data for 100,000 users. If a single control on this checklist is missing, an attacker can compromise all of it in under an hour. The OWASP API Security Top 10 lists broken object-level authorization as the #1 risk for 2023 — and it’s a one-line fix that too many teams skip.

This checklist is the verification layer. It assumes you already have an API in production (or about to ship one) and need to confirm that the essential security controls are in place. Each section pairs a short explanation with a runnable code pattern and a checklist you can tick off. For the full implementation deep-dive on any topic, see the companion Complete Guide to API Security.

The checklist covers nine control families: authentication, authorization, input validation, rate limiting, encryption, error handling, logging, CORS/headers, and deployment hardening. Work through them in order — the first four cover roughly 80% of real-world API vulnerabilities.

When to Use

Apply this checklist when:

  • You’re about to ship a new API to production and need a final security gate.
  • You’ve inherited an existing API with no documented security controls.
  • A penetration test or security audit is scheduled and you want to self-assess first.
  • You’re reviewing a pull request that touches authentication, authorization, or input handling.
  • You’re hardening a public-facing API after a near-miss or reported vulnerability.

This checklist is not the right tool when you need a from-scratch security architecture — that’s the Complete Guide to API Security. It also doesn’t cover compliance frameworks (SOC 2, PCI DSS, HIPAA) — those need their own scoping exercise. If your API is fully internal, isolated on a private network with no external exposure, many of these controls are still worth applying but the priority order changes.

1. Authentication

Use Strong Token-Based Authentication

# Bad: API keys passed in query strings (logged by proxies)
GET /data?api_key=abc123

# Good: Bearer tokens in Authorization header
GET /data
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

Implement JWT Securely

import jwt
from datetime import datetime, timedelta

def create_access_token(user_id, secret, algorithm="HS256"):
    payload = {
        "sub": str(user_id),
        "iat": datetime.utcnow(),
        "exp": datetime.utcnow() + timedelta(minutes=15),
        "jti": str(uuid.uuid4())  # unique token ID for revocation
    }
    return jwt.encode(payload, secret, algorithm=algorithm)

For Node.js, the same pattern with jsonwebtoken:

const jwt = require("jsonwebtoken");
const crypto = require("crypto");

function createAccessToken(userId, secret) {
  return jwt.sign(
    {
      sub: String(userId),
      iat: Math.floor(Date.now() / 1000),
      jti: crypto.randomUUID(),
    },
    secret,
    { algorithm: "HS256", expiresIn: "15m" }
  );
}

Algorithm choice matters. HS256 (HMAC with a shared secret) is fine for a single service that issues and verifies its own tokens. The moment a second service needs to verify tokens, switch to RS256 (RSA signature): the issuer keeps the private key, every verifier uses the public key. Never accept alg: "none" — the none algorithm was a real CVE in several JWT libraries and lets anyone forge tokens.

The diagram below shows the access-token / refresh-token rotation flow. The client never stores the access token in localStorage — it lives in an HttpOnly cookie. When the access token expires, the client calls /refresh with the refresh token, which is itself rotated on every use so a stolen refresh token works exactly once.

flowchart diagram: Client

Requirements Checklist

  • Use HTTPS everywhere (no HTTP fallback)
  • Tokens expire in 15 minutes or less (access tokens)
  • Refresh tokens expire in 7-30 days with rotation
  • Store tokens securely (HttpOnly cookies for browser clients)
  • Reject tokens with weak signatures (none, none256)
  • Use RS256 (asymmetric) when more than one service verifies tokens
  • Include a jti claim so tokens can be revoked via a denylist (Redis)

2. Authorization

Enforce Least Privilege

# Bad: admin check is missing
def delete_user(user_id):
    db.execute("DELETE FROM users WHERE id = %s", user_id)

# Good: verify authorization before action
def delete_user(requesting_user, target_user_id):
    if not requesting_user.has_role("admin"):
        raise Forbidden("Admin role required")
    if requesting_user.id == target_user_id:
        raise BadRequest("Cannot delete yourself")
    db.execute("DELETE FROM users WHERE id = %s", target_user_id)

Stop IDOR Before It Ships

Insecure Direct Object Reference (IDOR) is the #1 API risk in the OWASP API Security Top 10. The bug is simple: the endpoint trusts the id from the URL without checking that the caller owns that resource. /api/orders/1234 returns the order regardless of who’s asking.

The fix is a one-line ownership check on every resource-fetching endpoint:

# Bad: trusts the ID from the URL
order = db.get_order(request.args["id"])
return order

# Good: scope the query to the authenticated user
order = db.get_order(id=request.args["id"], owner_id=request.user.id)
if order is None:
    raise NotFound  # 404, not 403, to avoid leaking existence

Returning 404 instead of 403 when a resource exists but isn’t owned by the caller prevents enumeration — an attacker can’t distinguish “doesn’t exist” from “exists but isn’t yours”.

Use Unpredictable Identifiers

Sequential IDs (/user/1, /user/2) let attackers enumerate every record by simply incrementing the number in the URL. If you switch to UUIDs (v4 or v7), the identifier space becomes too large to brute-force — an attacker can’t guess /user/550e8400-e29b-41d4-a716-446655440000 by trying numbers. This doesn’t replace authorization checks, but it removes one trivial attack vector.

Checklist

  • Authenticate before authorizing (no auth bypass)
  • Validate resource ownership (user A can’t access user B’s data)
  • Role-based access control (RBAC) or attribute-based (ABAC)
  • Deny by default — explicitly allow, don’t implicitly trust
  • Return 404 (not 403) for resources the caller doesn’t own, to prevent enumeration
  • Use UUIDs instead of sequential IDs to prevent enumeration attacks

3. Input Validation

Validate Everything

from pydantic import BaseModel, Field, validator

class CreateUserRequest(BaseModel):
    email: str = Field(..., min_length=5, max_length=254)
    password: str = Field(..., min_length=12, max_length=128)

    @validator("email")
    def validate_email(cls, v):
        if "@" not in v:
            raise ValueError("Invalid email format")
        return v.lower().strip()

The equivalent in Node.js with Zod:

const { z } = require("zod");

const CreateUserRequest = z.object({
  email: z.string().email().min(5).max(254).transform((v) => v.toLowerCase().trim()),
  password: z.string().min(12).max(128),
  role: z.enum(["user", "admin"]).default("user"),
}).strict(); // .strict() rejects unexpected fields — prevents mass assignment

The .strict() call is what stops mass assignment. Without it, a request like {"email": "...", "password": "...", "role": "admin"} silently sets the user’s role to admin. With strict mode, the extra role field is rejected with a 400. Mass assignment is how several high-profile account takeovers happened — always strip or reject fields the client shouldn’t control.

Checklist

  • Validate type, length, format, and range for every input
  • Reject unexpected fields (strict schema validation — prevents mass assignment)
  • Sanitize file uploads (extension, MIME type, size limits)
  • Use parameterized queries (prevent SQL injection)
  • Encode output to prevent XSS
  • Set a maximum payload size (1 MB default) to prevent DoS via huge bodies

4. Rate Limiting

Prevent Abuse

from flask_limiter import Limiter

limiter = Limiter(
    key_func=lambda: request.headers.get("Authorization"),
    default_limits=["100 per minute"]
)

@app.route("/api/login", methods=["POST"])
@limiter.limit("5 per minute")
def login():
    ...

Differentiate Limits by Endpoint Sensitivity

A single global rate limit is rarely the right choice. Login and password-reset endpoints need strict limits (5 requests per minute per IP) to slow credential-stuffing attacks. Read endpoints can relax (100-1000 per minute per user) because a GET doesn’t mutate state. Write endpoints need a middle ground — strict enough to prevent abuse, loose enough not to block legitimate use. The API Gateway Design Guide covers how to centralize this at the gateway layer so individual services don’t each have to re-implement it.

The Retry-After header on a 429 response isn’t optional — without it, well-behaved clients can’t back off and will hammer the endpoint in a tight retry loop. Badly-behaved clients will hammer it anyway, which is why rate limiting pairs with alerting: a sudden spike in 429s on a single IP is a signal worth paging on.

Checklist

  • Different limits per endpoint (stricter for auth, looser for read)
  • Per-user and per-IP rate limits
  • Return 429 Too Many Requests with Retry-After header
  • Log and alert on repeated violations
  • Apply stricter limits to login, password-reset, and signup endpoints

5. Encryption

Data in Transit

  • TLS 1.2+ minimum; prefer TLS 1.3 for new deployments (faster handshake, fewer round-trips)
  • Strong cipher suites (no RC4, DES, MD5, 3DES)
  • HSTS header to prevent downgrade attacks (max-age=31536000; includeSubDomains)
  • Certificate pinning for mobile clients (prevents MITM via compromised CAs)

The TLS version requirement is a moving target. TLS 1.0 and 1.1 are deprecated (RFC 8996) and disabled by default in modern browsers. TLS 1.2 is the current floor; TLS 1.3 is the recommended default for new APIs because it removes vulnerable cipher suites entirely and cuts the handshake from two round-trips to one.

Data at Rest

from cryptography.fernet import Fernet

key = Fernet.generate_key()
cipher = Fernet(key)

# Encrypt sensitive fields
encrypted_ssn = cipher.encrypt(b"123-45-6789")
decrypted = cipher.decrypt(encrypted_ssn)

The encryption key must never live in source code, environment variables committed to git, or config files. Use a managed key service (AWS KMS, Google Cloud KMS, Azure Key Vault) or a secrets manager like HashiCorp Vault. Keys should rotate on a schedule — annually at minimum, immediately on any suspected exposure. For the full pattern set — AES-256-GCM, envelope encryption, and per-field key versioning — see the encryption at rest guide.

Password Hashing

Passwords aren’t encrypted — they’re hashed, and the hash is one-way. The algorithm matters:

AlgorithmStatusWhy
MD5, SHA1NeverToo fast — billions of guesses per second on a GPU
SHA256 (raw)NeverAlso too fast for password hashing
bcryptAcceptableAdaptive cost factor, battle-tested
Argon2idRecommendedWinner of the Password Hashing Competition, resistant to GPU/ASIC attacks
scryptAcceptableMemory-hard, good alternative to Argon2

Checklist

  • TLS 1.2+ for all API communication (prefer 1.3 for new deployments)
  • Encrypt sensitive data at rest (PII, credentials, tokens)
  • Hash passwords with bcrypt/Argon2id (never MD5 or SHA1)
  • Secure key management (KMS, HSM, or vault — not in code)
  • Rotate encryption keys annually or on suspected exposure
  • Set HSTS with includeSubDomains and a max-age of at least one year

6. Error Handling

Don’t Leak Information

# Bad: exposes internal details
except DatabaseError as e:
    return {"error": str(e)}  # reveals schema, query structure

# Good: generic message, log details server-side
except DatabaseError as e:
    logger.error("Database error", exc_info=e, extra={"request_id": request.id})
    return {"error": "Internal server error"}, 500

A raw exception message like duplicate key value violates unique constraint "users_email_key" tells an attacker the table is named users and the column is email. That’s free reconnaissance. The client gets a generic message; the server-side log keeps the full detail with a correlation ID so you can trace the request during incident response.

Use a Consistent Error Format

RFC 7807 defines a standard JSON shape for API errors:

{
  "type": "https://stackpractices.com/errors/forbidden",
  "title": "Forbidden",
  "status": 403,
  "detail": "You do not have access to this resource.",
  "instance": "/api/orders/1234"
}

Clients can parse the type URI to look up documentation, and the instance field helps you correlate the error in your logs. This beats ad-hoc {"error": "..."} shapes that vary per endpoint.

Checklist

  • Generic error messages to clients
  • Detailed logs server-side (with correlation IDs)
  • Consistent error format (RFC 7807 Problem Details)
  • Don’t expose stack traces, file paths, or system info
  • Disable debug mode and verbose error pages in production

7. Logging and Monitoring

What to Log

EventData to LogData to Avoid
AuthenticationSuccess/failure, timestamp, IPPasswords, tokens
Authorization failuresResource, action, userSensitive payload
Rate limit hitsUser/IP, endpointFull request body
ErrorsError type, request ID, endpointStack traces in client logs

The single most common logging mistake is writing the full request body — which often contains passwords, tokens, or PII — into a log file that gets shipped to a centralized system with weaker access controls than the database. Treat logs as a data store with its own threat model. If you wouldn’t put a field in a public API response, don’t put it in a log line that ends up in Elasticsearch.

Checklist

  • Log all authentication attempts (success and failure)
  • Alert on anomalous patterns (unusual IPs, volume spikes)
  • Retain logs for incident investigation (30-90 days)
  • Centralized log aggregation (SIEM or equivalent)
  • Never log passwords, tokens, or full request bodies
  • Include a correlation ID on every request for tracing

8. CORS and Headers

# Strict CORS policy
from flask_cors import CORS

CORS(app, origins=["https://app.example.com"], supports_credentials=True)

# Security headers
@app.after_request
def add_security_headers(response):
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["Content-Security-Policy"] = "default-src 'self'"
    response.headers["Strict-Transport-Security"] = "max-age=31536000"
    return response

Checklist

  • Restrict CORS to known origins (no * with credentials)
  • Set security headers on all responses
  • Disable server version banners (nginx, Apache, framework)

9. Deployment Hardening

  • Run API in isolated network (VPC, private subnets)
  • Use a Web Application Firewall (WAF)
  • Keep dependencies updated (automated vulnerability scanning)
  • Disable unused endpoints and HTTP methods
  • Run with least-privilege OS user (not root)

Common Mistakes

  • Trusting client-side validation (always validate server-side)
  • Storing secrets in environment variables without rotation
  • Using predictable IDs (/user/1, /user/2) without authorization checks
  • Missing pagination limits (DoS via huge ?limit=999999)
  • CORS set to * in production

Advanced Topics

Scenario: REST API Hardening for Production

What follows is a complete production checklist for a Node.js REST API serving 100K users. I’ve grouped the 47 controls by category so you can tackle them in order of risk. Each row links back to the section that explains the why.

Authentication and access tokens. This is your front door. If any of these fail, an attacker can impersonate any user. JWT with RS256 matters because HS256 forces you to share the signing secret with every verifier — once a second service needs to validate tokens, you’ve handed over the keys.

ControlSection
JWT with RS256 (not HS256)1. Authentication
Token expiry: 15 min access, 7 days refresh1. Authentication
Refresh token rotation1. Authentication
MFA for admin endpoints1. Authentication
Rate limiting on login: 5 attempts, then lock 15 min4. Rate Limiting
No token in URL1. Authentication
Logout invalidates token (Redis denylist)1. Authentication
Password policy: min 12 chars, complexity5. Encryption

Authorization and IDOR. Broken object-level authorization is the #1 API risk in the OWASP Top 10. Every row here exists because someone, somewhere, shipped an endpoint that trusted the URL parameter without checking ownership.

ControlSection
RBAC: roles user / admin / super_admin2. Authorization
Verify ownership on every request (anti-IDOR)2. Authorization
Scope per resource: user only accesses their data2. Authorization
Deny by default, allow explicitly2. Authorization
No auto-increment IDs (use UUID)2. Authorization

Input validation. The goal is simple: never trust anything the client sends. Schema validation on every endpoint catches mass assignment and injection before they reach your database. A 1 MB payload limit stops attackers from crashing your server with a 500 MB JSON blob.

ControlSection
Schema validation (Zod) on every endpoint3. Input Validation
Payload size limit (max 1 MB)3. Input Validation
String sanitization (no HTML injection)3. Input Validation
Parameterized queries (anti-SQL injection)3. Input Validation
No eval/exec with user input3. Input Validation
File upload: validate type, size, content3. Input Validation

Output and error handling. What your API returns is just as important as what it accepts. Stack traces leak file paths and library versions. A missing DTO mapping can expose internal fields like is_admin or created_by.

ControlSection
No stack traces in production6. Error Handling
DTO mapping: no internal fields exposed6. Error Handling
Headers: X-Content-Type-Options, X-Frame-Options, CSP, HSTS8. CORS and Headers
No server/framework version exposed8. CORS and Headers
Global rate limiting: 100 req/min per user4. Rate Limiting

Transport encryption. TLS 1.3 is non-negotiable. HSTS with a one-year max-age tells browsers to always use HTTPS, preventing SSL strip attacks on public Wi-Fi. Certificate pinning matters for mobile apps where the OS certificate store is less trustworthy.

ControlSection
TLS 1.3 mandatory (no TLS 1.0/1.1)5. Encryption
Redirect HTTP to HTTPS5. Encryption
HSTS: max-age=31536000; includeSubDomains5. Encryption
Certificate pinning (mobile apps)5. Encryption

Configuration and secrets. A wildcard CORS policy (Access-Control-Allow-Origin: *) lets any website call your API with the user’s cookies. Secrets in .env files work for development but in production you need a secrets manager with rotation.

ControlSection
CORS: strict origin, no wildcard8. CORS and Headers
NODE_ENV=production9. Deployment Hardening
Secrets in Secrets Manager (no .env in prod)5. Encryption
Helmet() configured8. CORS and Headers
Compression with Brotli (not gzip to avoid BREACH)5. Encryption

Logging and monitoring. Without audit logs and alerting, you find out about breaches from your users — or from a journalist. The rule is: log enough to reconstruct what happened, but never log secrets, tokens, or PII.

ControlSection
Audit log of critical actions7. Logging and Monitoring
No logging secrets, passwords, tokens, PII7. Logging and Monitoring
Alerts for failed auth attempts7. Logging and Monitoring
Alerts for rate limit exceeded7. Logging and Monitoring
SIEM integration (ELK + alerting)7. Logging and Monitoring

Dependencies and CI/CD. The last layer is your supply chain. A vulnerable npm package or a leaked API key in git can undo every control above. These items make security a CI gate, not a manual review.

ControlSection
npm audit in CI (—audit-level=high)9. Deployment Hardening
Dependabot or Snyk configured9. Deployment Hardening
Lockfile committed (package-lock.json)9. Deployment Hardening
License check in CI9. Deployment Hardening
SAST (semgrep) in pipeline9. Deployment Hardening
DAST (OWASP ZAP) on staging9. Deployment Hardening
Container scan (Trivy) in build9. Deployment Hardening
Secret scan (git-secrets) in pre-commit9. Deployment Hardening
Code review mandatory (1 approver min)9. Deployment Hardening

Lessons from running this checklist in production:

  • 47 items is the floor for a production API, not the ceiling.
  • IDOR is the #1 risk: verify ownership on every request, no exceptions.
  • Schema validation on every endpoint catches mass assignment and injection before they reach your database.
  • Audit logging plus alerting gives you early detection — without it, you find out about breaches from your users.
  • SAST, DAST, and secret scanning in CI are the only way to prevent regressions as the team grows.

Best Practices

  • Rotate JWT signing keys every 90 days with key versioning. Embed the key ID in the kid header so verifiers can fetch the right public key without downtime during rotation.
  • Apply different rate limits per endpoint class. Login and password-reset: 5 req/min per IP. Read endpoints: 100 req/min per user. Write endpoints: 30 req/min per user. A single global limit either blocks legitimate traffic or lets abuse through.
  • Return 404, not 403, for resources the caller doesn’t own. A 403 confirms the resource exists, which lets attackers enumerate. A 404 gives them nothing.
  • Treat logs as a data store with their own threat model. If a field shouldn’t be in a public API response, it shouldn’t be in a log line that ends up in Elasticsearch with weaker access controls.
  • Wire SAST and secret scanning into pre-commit, not just CI. Catching a hardcoded secret before it reaches the remote is infinitely cheaper than rotating it after a push.

Frequently Asked Questions

Should I use OAuth 2.0 or API keys for my API?

OAuth 2.0 for user-facing APIs with third-party integrations. API keys are fine for server-to-server where the key is kept secret.

How often should I rotate signing keys?

At least annually, or immediately if compromised. Use key versioning to rotate without downtime.

Is GraphQL less secure than REST?

Neither is safer — they just fail differently. GraphQL gives attackers a powerful query language, so you need depth limits, complexity scoring, and field-level auth checks to stop them from requesting your entire database in one query. REST is simpler but inherits the same IDOR and validation issues. For the GraphQL-specific defenses — introspection control, cost limits, persisted queries — see the Complete Guide to GraphQL Security.

How do I get started with this in an existing project?

Don't try to fix everything at once. Find the endpoint that handles the most sensitive data — usually auth or payments — and apply sections 1-3 (authentication, authorization, input validation) to just that one route. Ship it, watch the logs for a week, then move to the next endpoint. Incremental hardening beats a big-bang rewrite that never ships.

What tools do I need to run these checks in CI?

For Node.js: helmet for headers, zod for validation, express-rate-limit for throttling, semgrep for SAST, npm audit --audit-level=high for dependencies, and trivy for container scanning. For Python: pydantic for validation, flask-limiter for throttling, bandit for SAST, and pip-audit for dependencies.

How do I measure success after implementing this?

Track three metrics: failed authentication attempts per day (should stabilize, not grow), 429 rate-limit responses per day (spikes indicate abuse or a misconfigured client), and time-to-detect for any security incident (target: under 24 hours from audit log to alert).

How do I prioritize if I have limited time?

Start with authentication (secure JWT + rate limiting), authorization (verify ownership), input validation (Zod on every endpoint), and TLS. These four cover roughly 80% of vulnerabilities. Then add security headers (helmet), audit logging, and dependency scanning. Finally, wire SAST/DAST into CI. Treat the checklist as a recurring review, not a one-and-done gate — re-run it every quarter and after any major endpoint change.