Security for developers is one of the toughest subjects to understand. Keep things simple and include plenty of diagrams with concepts in your documentation. Do not try to invent something new with security; always use tried and true patterns that can be easily understood.
Before diving into specifics, understand the foundational principles that guide security decisions.
"Never trust, always verify." Zero Trust1 assumes that threats exist both outside and inside the network. Every request must be authenticated and authorised, regardless of where it originates.
Core tenets:
- Verify explicitly. Always authenticate and authorise based on all available data points.
- Use least privilege access. Limit user access with just-in-time and just-enough-access.
- Assume breach. Minimise blast radius and segment access. Verify end-to-end encryption.
Zero Trust is not a product you buy. It's an architecture approach that affects how you design every API interaction. See Microsoft's Zero Trust guidance2 and NIST SP 800-2073 for detailed implementation frameworks.
Layer your security controls. Don't rely on a single mechanism. If one layer fails, others should still protect the system. This principle is documented in NIST's cybersecurity framework4.
┌─────────────────────────────────────────┐
│ API Gateway │ ← Rate limiting, WAF
│ ┌─────────────────────────────────┐ │
│ │ Authentication │ │ ← Token validation
│ │ ┌─────────────────────────┐ │ │
│ │ │ Authorisation │ │ │ ← Permission checks
│ │ │ ┌──────────────────┐ │ │ │
│ │ │ │ Input Validation │ │ │ │ ← Schema validation
│ │ │ │ ┌───────────┐ │ │ │ │
│ │ │ │ │ Business │ │ │ │ │ ← Domain rules
│ │ │ │ │ Logic │ │ │ │ │
│ │ │ │ └───────────┘ │ │ │ │
│ │ │ └──────────────────┘ │ │ │
│ │ └─────────────────────────┘ │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
Each layer catches different types of threats.
The three pillars of information security, as defined by ISO/IEC 270015:
| Principle | Definition | API Implications |
|---|---|---|
| Confidentiality | Data is accessible only to authorised parties | Encryption in transit (TLS6), proper authorisation, field-level access control |
| Integrity | Data is accurate and unaltered | Input validation, checksums, audit logs, signed payloads |
| Availability | Systems are accessible when needed | Rate limiting, circuit breakers, DDoS protection, redundancy |
When designing security controls, consider which pillar you're protecting. Sometimes they conflict: aggressive rate limiting (availability) might make legitimate access harder (also availability). Find the right balance.
When something goes wrong, fail closed rather than open. An authentication service that crashes should deny access, not grant it. See OWASP's guidance on secure defaults7.
- Database unavailable? Deny requests. Don't skip authorisation.
- Token validation fails? Treat as unauthenticated.
- Rate limit service down? Apply conservative defaults, don't allow unlimited access.
- Configuration missing? Use secure defaults, not permissive ones.
This is harder than it sounds. The temptation is to "keep things working" but insecure working is worse than secure failing.
No single person or service should have complete control over critical operations. Break sensitive operations into steps that require multiple parties. This is a core principle in SOC 2 compliance8.
- Deployment to production requires code review approval
- Database admin cannot also be application admin
- Secret rotation requires approval from security team
- Privileged access requires justification and time-limited grants
For APIs, this means service accounts should have focused permissions, not "god mode" access.
These are different concerns:
- Authentication - Who are you? Verifying identity (login, credentials, tokens)
- Authorisation - What can you do? Checking permissions after identity is established
OAuth 2.09 is an authorisation framework, not authentication. It grants access to resources but doesn't verify identity. OpenID Connect (OIDC)10 builds on OAuth 2.0 to add authentication. When you "Login with Google," that's OIDC providing identity, with OAuth handling the access grants.
For APIs:
- Private/internal APIs - JWT11 tokens for stateless auth
- Public-facing APIs - OIDC for authentication + OAuth 2.0 for authorisation (use PKCE12 for mobile/SPA clients)
- Avoid Basic Auth - It's a standard for user authentication but not appropriate for application-to-application communication. See RFC 761713.
If you have to store credentials, never store the password. Use a dedicated password hashing algorithm designed for this purpose.
- Use Role-Based Access Control (RBAC)14
- Apply the Principle of Least Privilege15 (PoLP): grant only the minimum permissions required for the task
- Keep roles as simple as possible. They always become more complex as time evolves
- Use a grant-based permissions model, never restriction-based. Start with no access and explicitly grant what's needed
- Always add rate limiting to endpoints. Include rate limit status in response headers
PoLP Example:
A reporting service needs to read order data. The wrong approach grants broad access:
# Bad - too permissive
Role: reporting-service
Permissions: orders.*, customers.*, products.*
The right approach grants only what's needed:
# Good - minimal required access
Role: reporting-service
Permissions: orders.read, orders.list
Restrictions: only orders older than 24 hours
If the reporting service is compromised, the attacker can only read historical orders. They can't modify orders, access customers, or see real-time data. The blast radius is contained.
Trust is not binary. Systems operate within concentric circles of trust, where inner circles have higher trust levels than outer circles.
┌─────────────────────────────────────────────────────────┐
│ Public Internet │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Partner Network │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ Corporate Network │ │ │
│ │ │ ┌─────────────────────────────────┐ │ │ │
│ │ │ │ Service Mesh │ │ │ │
│ │ │ │ ┌─────────────────────────┐ │ │ │ │
│ │ │ │ │ Internal Services │ │ │ │ │
│ │ │ │ └─────────────────────────┘ │ │ │ │
│ │ │ └─────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Trust levels and their implications:
| Circle | Trust Level | Authentication Required | Example |
|---|---|---|---|
| Public Internet | None | Full auth + rate limiting | Mobile app users |
| Partner Network | Low | API keys + mutual TLS16 | Third-party integrations |
| Corporate Network | Medium | SSO + network controls | Internal tools |
| Service Mesh | High | Service identity (mTLS) | Microservices |
| Internal Services | Highest | Implicit trust | Same-process calls |
Key principles:
- Never trust based on network location alone. VPNs get compromised. Assume breach.
- Authenticate at every boundary crossing. When a request moves from one circle to another, re-verify.
- Least privilege still applies. Even trusted internal services should only access what they need.
- Trust degrades over time. Session tokens from high-trust contexts should expire faster when used in lower-trust contexts.
Federation across trust boundaries:
When systems in different trust circles need to communicate, use federation protocols:
- SAML 2.017 for enterprise SSO across organisations
- OIDC10 for identity federation with external providers
- OAuth 2.09 for delegated access across trust boundaries
The identity provider acts as a trust broker, vouching for identities across circles.
If you have to store passwords, use a proper password hashing algorithm:
- Argon2id18 - The current recommendation, winner of the Password Hashing Competition19
- bcrypt20 - Battle-tested and widely available
- scrypt21 - Good alternative with memory-hard properties
Never use general-purpose hash functions (MD5, SHA-256) for passwords. These are too fast and vulnerable to brute-force attacks. Password hashing algorithms are intentionally slow and include built-in salting. See OWASP Password Storage Cheat Sheet22.
// Example using bcrypt (Node.js)
const bcrypt = require('bcrypt');
const saltRounds = 12;
// Hashing
const hash = await bcrypt.hash(password, saltRounds);
// Verification
const match = await bcrypt.compare(password, hash);OTPs provide a second authentication factor. Something the user knows (password) plus something the user has (device generating OTP). See NIST SP 800-63B23 for authentication assurance levels.
TOTP24 generates codes based on current time and a shared secret. Codes are valid for a short window (typically 30 seconds).
Code = HMAC-SHA1(secret, floor(timestamp / 30)) → 6 digits
Advantages:
- Works offline (no network required)
- Standardised (RFC 6238)
- Supported by authenticator apps (Google Authenticator25, Authy26, 1Password27)
Implementation notes:
- Store the shared secret encrypted, not plaintext
- Allow for clock drift (accept codes from adjacent time windows)
- Provide backup codes for account recovery
- Rate limit verification attempts
HOTP28 generates codes based on a counter rather than time. Each code is valid until used.
Code = HMAC-SHA1(secret, counter) → 6 digits
Less common than TOTP. Used in hardware tokens where time synchronisation is difficult.
Sending codes via SMS or email is convenient but less secure:
| Method | Pros | Cons |
|---|---|---|
| SMS | Familiar to users, no app needed | SIM swapping attacks29, SS7 vulnerabilities30, delivery delays |
| No phone needed | Email accounts get compromised, delivery delays | |
| Authenticator App | Most secure, works offline | Requires app installation, device dependency |
If you must use SMS OTP:
- Use it as a fallback, not the primary method
- Implement rate limiting aggressively
- Monitor for SIM swap indicators (sudden carrier changes)
- Consider voice calls as an alternative
- Short expiry - TOTP: 30-60 seconds. SMS/Email: 5-10 minutes max
- Single use - Once verified, invalidate the code
- Rate limiting - Max 3-5 attempts per code, then require new code
- Constant-time comparison - Prevent timing attacks31 when verifying
- Secure delivery - For SMS/Email, don't include what the code is for ("Your code is 123456" not "Your password reset code is 123456")
// Constant-time comparison to prevent timing attacks
function verifyOTP(provided, expected) {
if (provided.length !== expected.length) return false;
let result = 0;
for (let i = 0; i < provided.length; i++) {
result |= provided.charCodeAt(i) ^ expected.charCodeAt(i);
}
return result === 0;
}Use TLS 1.36 everywhere, no exceptions. Don't worry about debugging the payload. Tools like Charles Proxy32, mitmproxy33, or browser developer tools can help you inspect traffic during development. See Mozilla's TLS configuration recommendations34.
Use JWT (JSON Web Tokens)11 for stateless authentication. JWTs have been around for years, are easy to explain, and the internet is rich with examples. See jwt.io35 for an interactive debugger.
JWT provides access to claims. You can include:
- Version number - Notify clients when the app version is outdated
- Roles - Allow clients to adjust presentation based on permissions
- Subject name - Display user information without additional API calls
Claims can be decoded (they're Base64-encoded), providing metadata to the client. However, never put information in the JWT that could compromise the user account if exposed.
Always verify JWT signatures. This sounds obvious, but it's a common vulnerability. Never trust a token just because it parses correctly. See Auth0's JWT vulnerabilities guide36.
Critical rules:
- Declare your algorithm explicitly - Never trust the
algheader in the token. Attackers can setalg: noneor switch from RS256 to HS256 to bypass verification. Your server must enforce which algorithm it accepts. See CVE-2015-923537. - Validate the issuer (
iss) - Ensure the token came from your identity provider - Check expiration (
exp) - Reject expired tokens - Check not-before (
nbf) - Reject tokens that aren't yet valid. Prevents tokens issued for future use from being used early. - Verify audience (
aud) - Ensure the token was intended for your API
// Bad - trusts the token's algorithm claim
jwt.verify(token, secret);
// Good - explicitly declares expected algorithm
jwt.verify(token, publicKey, { algorithms: ['RS256'], issuer: 'https://auth.example.com' });The jti (JWT ID) claim is a unique identifier for each token. Use it to:
- Prevent replay attacks - Track which tokens have been used
- Enable revocation - Invalidate specific tokens before expiry
- Bind tokens to devices - Associate JTI with device fingerprints
For distributed systems, store JTI state in Redis38 or similar:
jti:abc123 -> { deviceFingerprint: "x1y2z3", issuedAt: 1699876543, revoked: false }
On each request:
- Extract JTI from token
- Look up JTI in Redis
- Verify device fingerprint matches the request
- Reject if revoked or fingerprint mismatch
This catches token theft; even with a valid token, an attacker's device fingerprint won't match. See Rate Limiting Keys for how to combine JTI tracking with rate limiting in a single Redis lookup.
Never store JWTs in localStorage or sessionStorage. These are accessible to any JavaScript running on the page, making them vulnerable to XSS attacks39.
Instead, use HTTP-only cookies:
Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Strict; Path=/
- HttpOnly - JavaScript cannot access the cookie
- Secure - Only sent over HTTPS
- SameSite=Strict40 - Prevents CSRF by not sending on cross-origin requests
Use a two-token pattern as described in OAuth 2.0 Security Best Practices41:
| Token | Lifetime | Storage | Purpose |
|---|---|---|---|
| Access token | 15-20 minutes | HTTP-only cookie or memory | Authorise API requests |
| Refresh token | Days/weeks | HTTP-only cookie (separate path) | Obtain new access tokens |
The refresh flow:
- Access token expires
- Client calls
/auth/refreshwith refresh token - Server validates refresh token, issues new access token
- Old refresh token is invalidated (rotation)
Keep refresh tokens on a separate path (Path=/auth/refresh) so they're only sent to the refresh endpoint.
Not all authenticated requests should be treated equally. A user reading their profile is low risk. A user changing their password or email is high risk. Risk-Based Assessment evaluates context and adjusts security requirements accordingly. See NIST SP 800-63B23 for guidance on adaptive authentication.
Building a user behaviour baseline:
RBA requires tracking user interactions over time to establish what's "normal" for each user. Store this in your database:
user_sessions: {
userId: "user_123",
knownDevices: ["fp_abc123", "fp_def456"],
knownIPs: ["203.0.113.0/24"],
knownLocations: ["Sydney, AU", "Melbourne, AU"],
typicalHours: { start: 7, end: 22, timezone: "Australia/Sydney" },
lastLogin: "2024-01-15T09:30:00Z",
loginHistory: [{ timestamp, device, ip, location, success }],
failedAttempts: { count: 0, lastAttempt: null }
}
Risk signals to evaluate:
- New device or browser fingerprint
- New geographic location or IP address
- Unusual time of day for this user
- Multiple failed attempts recently
- Velocity (too many requests in short time)
- Value of the action (viewing vs transferring money)
Scoring the request:
Assign points to each risk signal and sum them:
| Signal | Points |
|---|---|
| Known device | 0 |
| New device, known location | +10 |
| New device, new location | +30 |
| New country | +50 |
| Outside typical hours | +10 |
| Failed attempts > 3 in last hour | +20 |
| First-time login (new account) | +25 |
| Sensitive operation | +20 |
| Impossible travel (Sydney to London in 1 hour) | +100 |
Then map total score to response:
- 0-20: Allow
- 21-50: Challenge (MFA)
- 51+: Block or require enhanced verification
Handling first-time interactions:
New users and new devices have no baseline. Handle this by:
- New accounts - Require email/phone verification before allowing sensitive operations
- New devices - Send notification to known email: "New login from Chrome on Windows"
- First login from location - Challenge with MFA, then add to known locations if successful
- Grace period - For the first 7-14 days, apply stricter thresholds until baseline is established
The goal is to make low-risk actions frictionless for established users while adding friction when something looks unusual.
Response levels:
- Allow - Low risk, proceed normally
- Challenge - Medium risk, require step-up authentication (MFA, re-enter password)
- Block - High risk, deny and alert
Sensitive operations that should always require step-up authentication:
- Password changes
- Email or phone number changes
- Adding or changing payment methods
- Enabling/disabling MFA
- Deleting account
- Exporting personal data
- Granting elevated permissions
POST /users/123/change-password
Authorization: Bearer <access_token>
X-Step-Up-Token: <recent_mfa_token>
{
"currentPassword": "...",
"newPassword": "..."
}Even with a valid session, require the user to re-authenticate for these operations. The step-up token should be short-lived (5-10 minutes) and single-use.
Password change flow example:
- User clicks "Change Password"
- API returns
403withX-Step-Up-Required: true - Client prompts for current password or MFA
- Client exchanges credentials for step-up token
- Client retries with step-up token
- API validates step-up token is recent and unused
- Password change proceeds
This prevents attackers with stolen session tokens from locking users out of their accounts or redirecting password resets to attacker-controlled emails.
- JWS42 (JSON Web Signature) - Signed but readable. Claims are Base64-encoded, anyone can decode them. Use when claims aren't sensitive.
- JWE43 (JSON Web Encryption) - Encrypted payload. Only the server can read claims. Use when tokens contain sensitive data or you don't want clients inspecting claims.
Most APIs use JWS. Consider JWE if you're passing tokens between services and want to prevent intermediate systems from reading claims.
Perimeter security assumes everything inside the network is trusted. Authenticate at the edge (API gateway), then internal services trust each other. Simple but vulnerable if the perimeter is breached.
Distributed security (zero trust) assumes nothing is trusted. Every service validates tokens and enforces authorisation. More resilient but requires consistent identity propagation.
For APIs, distributed security means:
- Pass identity tokens between services
- Each service validates and authorises independently
- Use service-to-service authentication (mTLS16, service accounts)
Identity Management is the foundation. You need a central source of truth for who users are, what groups they belong to, and what attributes they carry. In enterprises this is typically Active Directory44 or Azure AD. For modern applications, identity providers like Keycloak45, Okta46, or Auth047 give you user management, federation, and social login out of the box.
IAM (Identity and Access Management) builds on identity to answer: what can this user do? IAM systems manage:
- Authentication policies (MFA, passwordless, SSO)
- Authorisation rules (roles, permissions, policies)
- Federation and trust relationships between systems
OAuth 2.09 and OpenID Connect10 are the protocols that tie this together. OAuth handles authorisation (what can you access), OIDC adds identity (who are you). Your API validates tokens issued by the identity provider; it doesn't manage users directly.
PAM (Privileged Access Management) is critical and often overlooked. PAM controls access to sensitive systems: database credentials, admin consoles, production infrastructure. Key capabilities:
- Just-in-time access - Elevated privileges granted temporarily, not permanently
- Credential vaulting - Secrets stored and rotated automatically, never exposed to humans
- Session recording - Full audit trail of privileged actions
- Approval workflows - Require sign-off before granting sensitive access
Tools like HashiCorp Vault48, CyberArk49, or AWS Secrets Manager50 handle credential management. For API development, this means your services retrieve database passwords and API keys from a vault at runtime, not from config files or environment variables baked into deployments.
Don't build identity infrastructure yourself. Integrate with established systems and focus on your domain.
Rate limiting prevents abuse, protects against DoS attacks, and ensures fair resource allocation. See the IETF Rate Limit Headers draft51 for standardised headers.
Use standard headers to communicate rate limit status:
RateLimit-Limit: 100
RateLimit-Remaining: 45
RateLimit-Reset: 1699876543
When rate limited, return 429 Too Many Requests with a Retry-After header per RFC 658552.
What you rate limit by matters:
- IP address - Simple but problematic behind NAT/proxies (entire offices share one IP)
- User ID - Fair per-user limits, but requires authentication
- JTI + Device fingerprint - Granular control per session/device
For authenticated APIs, tie rate limiting to the same Redis38 store as your JTI tracking. One lookup gives you token validation, device binding, and rate limit state:
session:{jti} -> {
userId: "user_123",
deviceFingerprint: "x1y2z3",
requests: 47,
windowStart: 1699876000,
revoked: false
}
Device fingerprinting is useful but imperfect. See FingerprintJS53 for implementation approaches.
- Browser fingerprints drift - Updates, extensions, and settings changes alter fingerprints over time. Use fuzzy matching, not exact equality.
- Privacy tools defeat fingerprinting - Tor54, VPNs, and privacy browsers intentionally obscure fingerprints. Decide if this blocks access or just increases scrutiny.
- Mobile fingerprints are less stable - App updates, OS updates, and carrier changes affect device IDs.
- Fingerprinting has legal implications - GDPR55 and similar regulations may consider fingerprints personal data.
Treat fingerprints as one signal among many, not absolute truth. Combine with other factors: IP geolocation, request patterns, time of day.
Decide your session policy upfront:
Single session only - One active session per user. New login invalidates previous sessions. Simpler, more secure, but frustrating for users with multiple devices.
// On new login, revoke all existing sessions for this user
await redis.del(`sessions:user:${userId}:*`);
await redis.set(`sessions:user:${userId}:${newJti}`, sessionData);Multiple sessions allowed - Users can be logged in on phone, tablet, and desktop simultaneously. Better UX, but requires:
- Session listing UI ("you're logged in on 3 devices")
- Individual session revocation
- Maximum session limits (e.g., no more than 5 active sessions)
Concurrent session limits - Allow N simultaneous sessions. New login beyond the limit either fails or evicts the oldest session.
For high-security applications (banking, healthcare), consider single session with step-up authentication for sensitive operations. See OWASP Session Management Cheat Sheet56.
Not all MFA is equal. Ranked by security strength:
- Passkeys / FIDO2 / WebAuthn57 — Phishing-resistant, hardware-bound. The gold standard per NIST SP 800-63-458. Passkeys replace passwords entirely and are not susceptible to phishing.
- TOTP (authenticator apps) — Good, works offline, widely supported.
- SMS / Email OTP — Fallback only. NIST SP 800-63-4 has significantly downgraded SMS-based authentication. Not acceptable for high-assurance scenarios due to SIM swapping and SS7 vulnerabilities.
When possible, implement FIDO2/WebAuthn/passkeys as the primary path. SMS OTP should require explicit risk acceptance documentation.
For recovery: provide backup codes, but ensure they're treated with the same sensitivity as passwords.
Broken Object Level Authorization (BOLA)59 is the #1 risk in the OWASP API Security Top 10. It occurs when an API allows a user to access another user's resources simply by changing an ID in the request.
GET /api/orders/1001 ← user's own order
GET /api/orders/1002 ← another user's order — should this be allowed?Defenses:
- Never trust client-supplied IDs alone. Always verify the requesting user owns or has permission to access the resource.
- Use opaque, non-sequential IDs (UUIDs, type-prefixed random IDs). Sequential integers make enumeration trivial.
- Enforce authorisation at the data layer, not just the route level.
// Bad - only checks authentication
const order = await Order.findById(req.params.id);
// Good - checks ownership
const order = await Order.findOne({ id: req.params.id, userId: req.user.id });
if (!order) return res.status(404).json({ error: 'Not found' });Bearer tokens are vulnerable: if a token is stolen, the attacker can use it from any device. DPoP (RFC 9449)60 binds an access token to a specific client's public key, making stolen tokens useless without the corresponding private key.
POST /token
DPoP: <signed-proof-JWT>
→ access_token bound to that key
→ subsequent requests must include matching DPoP proofDPoP is particularly important for public clients (SPAs, mobile apps) where token storage is inherently less secure. Consider it when operating under OAuth 2.1 requirements.
For SPAs and mobile apps, the BFF pattern61 keeps tokens entirely server-side:
Browser ←—— session cookie (HttpOnly) ——→ BFF Server ←—— access token ——→ API
The frontend never sees the token. The BFF exchanges the session cookie for an access token on each upstream call. This eliminates the XSS-via-localStorage attack surface entirely.
The BFF also handles CSRF protection (see below), token refresh, and can aggregate multiple API calls into a single response for the frontend.
Enumeration attacks probe an API systematically to discover valid resources, usernames, emails, or IDs. They require no authentication and exploit normal API behaviour.
Attack vectors:
| Endpoint | Attack | Example |
|---|---|---|
| Registration | Email enumeration | "email already taken" reveals the account exists |
| Login | Username enumeration | "user not found" vs "wrong password" differs |
| Password reset | Email enumeration | "email not found" reveals non-registration |
| Resource IDs | Object enumeration | Sequential IDs allow full catalog scraping |
Defences:
- Consistent responses. Return identical status codes, bodies, and response times regardless of whether the account/resource exists. Use constant-time operations to prevent timing attacks.
- Opaque, non-sequential IDs. UUID v4 has 2^122 possibilities — brute-force is infeasible.
- Aggressive rate limiting on auth endpoints. Login: 5-10 attempts/IP/min. Password reset: 3/email/hour.
- CAPTCHA after N failed attempts.
- Never expose "check availability" endpoints without rate limiting and CAPTCHA.
Every piece of information leaked through responses, headers, or error messages gives attackers a map of your internals.
Error responses:
- Suppress stack traces in production — they reveal framework, language, file paths, and dependency versions.
- Never expose raw database errors — SQL errors reveal table names, column names, and query structure.
- Use generic messages for internal failures: "An internal error occurred", not "NullPointerException in PaymentProcessor.java:142".
Response headers:
- Remove or genericise the
Serverheader. Don't advertiseServer: Apache/2.4.52 (Ubuntu). - Remove
X-Powered-By. Don't revealX-Powered-By: ExpressorX-Powered-By: PHP/8.2. - Avoid framework-specific cookie names (
JSESSIONID,PHPSESSID,connect.sid).
Authorisation errors:
For sensitive resources, return 404 Not Found for unauthorised access rather than 403 Forbidden. This prevents confirming the resource exists.
Cross-Site Request Forgery (CSRF) tricks an authenticated user's browser into making unintended requests. If your API uses cookie-based authentication (including HttpOnly cookies), CSRF protection is required — the browser sends cookies automatically, even on cross-site requests.
Key insight: CSRF is a cookie problem. Bearer tokens in
Authorizationheaders are not sent automatically by browsers, so CSRF does not apply to header-based auth.
Layered defences:
1. SameSite cookie attribute (primary defence)
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax
Strict— never sent on cross-site requests. Most secure, but breaks cross-site navigation (e.g., links from email).Lax— sent on top-level GET navigation, blocked on cross-site POST/PUT/DELETE. Good balance for most APIs.None— always sent; requiresSecure. Only for APIs that must be called cross-origin with cookies.
2. Anti-CSRF tokens (synchronizer token pattern)
Generate a server-side token tied to the session. Require it in a custom header (X-CSRF-Token) or form field on every state-changing request. Never put it in a cookie.
3. Fetch Metadata headers
Modern browsers send Sec-Fetch-Site, Sec-Fetch-Mode, and Sec-Fetch-Dest headers. The server can reject requests where Sec-Fetch-Site: cross-site for state-changing operations. These headers cannot be forged by JavaScript.
4. Re-authentication for sensitive operations
High-risk operations (money transfer, email change, account deletion) should require re-authentication regardless of CSRF protection. See the step-up authentication section above.
Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Security-Policy: default-src 'self'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY- HSTS62 — forces HTTPS for the declared period.
includeSubDomainsprevents subdomain bypass attacks. - CSP63 — restricts what resources the browser will load. Mitigates XSS impact.
- X-Content-Type-Options: nosniff — prevents MIME-type sniffing attacks.
- X-Frame-Options or
frame-ancestorsin CSP — prevents clickjacking.
Use Mozilla Observatory64 to audit your headers.
You cannot respond to what you cannot see. Plan logging and monitoring at design time.
Log all authentication events:
- Login success and failure (with reason, without exposing user existence)
- Token refresh and revocation
- MFA challenges and outcomes
- Step-up authentication triggers
Log all authorisation failures:
- Access denied events with the resource and action attempted
- BOLA attempts (user A trying to access user B's resource)
Forward to a SIEM or centralised logging system. Individual service logs are insufficient for detecting coordinated attacks (credential stuffing, distributed enumeration).
Anomaly detection:
- Credential stuffing: many failures across many accounts from few IPs
- Enumeration: sequential ID access patterns, high-volume 404s
- Impossible travel: same account authenticated from two distant locations within minutes
Audit logs must be tamper-resistant. Write-once storage, cryptographic chaining, or an append-only log service. Attackers who gain access will try to cover their tracks.
Review every API design against these risks65:
| # | Risk | What to Check |
|---|---|---|
| API1 | Broken Object Level Authorization | Can users access other users' resources by changing IDs? |
| API2 | Broken Authentication | Are auth mechanisms robust? Token handling secure? |
| API3 | Broken Object Property Level Authorization | Can users read/write properties they shouldn't? (mass assignment) |
| API4 | Unrestricted Resource Consumption | Are rate limits, pagination limits, and payload sizes enforced? |
| API5 | Broken Function Level Authorization | Can regular users access admin endpoints? |
| API6 | Unrestricted Access to Sensitive Business Flows | Can automated attacks exploit business logic? (scalping, credential stuffing) |
| API7 | Server-Side Request Forgery (SSRF) | Do any endpoints fetch external URLs from user input? |
| API8 | Security Misconfiguration | Are defaults secure? Is error info over-exposed? |
| API9 | Improper Inventory Management | Are all API versions documented? Are deprecated versions sunset? |
| API10 | Unsafe Consumption of APIs | Are third-party API responses validated before use? |
Written by Philip A Senger | LinkedIn | GitHub
This work is licensed under a Creative Commons Attribution 4.0 International License.
Previous: Pragmatism | Next: Design Principles
Footnotes
-
NIST. (2020). "Zero Trust Architecture." https://www.nist.gov/publications/zero-trust-architecture ↩
-
Microsoft. "Zero Trust Guidance." https://learn.microsoft.com/en-us/security/zero-trust/ ↩
-
Rose, S. et al. (2020). "Zero Trust Architecture." NIST Special Publication 800-207. https://csrc.nist.gov/publications/detail/sp/800-207/final ↩
-
NIST. "Cybersecurity Framework." https://www.nist.gov/cyberframework ↩
-
ISO. "ISO/IEC 27001 Information Security Management." https://www.iso.org/standard/27001 ↩
-
Rescorla, E. (2018). "The Transport Layer Security (TLS) Protocol Version 1.3." RFC 8446, IETF. https://datatracker.ietf.org/doc/html/rfc8446 ↩ ↩2
-
OWASP. "Proactive Controls C6: Implement Digital Identity." https://owasp.org/www-project-proactive-controls/v3/en/c6-implement-security-defenses ↩
-
AICPA. "SOC 2 - SOC for Service Organizations: Trust Services Criteria." https://www.aicpa.org/interestareas/frc/assuranceadvisoryservices/aaborgunits/soc2 ↩
-
OAuth 2.0. "OAuth 2.0 Authorization Framework." https://oauth.net/2/ ↩ ↩2 ↩3
-
OpenID Foundation. "OpenID Connect." https://openid.net/connect/ ↩ ↩2 ↩3
-
Jones, M., Bradley, J., and Sakimura, N. (2015). "JSON Web Token (JWT)." RFC 7519, IETF. https://datatracker.ietf.org/doc/html/rfc7519 ↩ ↩2
-
Sakimura, N. et al. (2015). "Proof Key for Code Exchange by OAuth Public Clients." RFC 7636, IETF. https://datatracker.ietf.org/doc/html/rfc7636 ↩
-
Reschke, J. (2015). "The 'Basic' HTTP Authentication Scheme." RFC 7617, IETF. https://datatracker.ietf.org/doc/html/rfc7617 ↩
-
NIST. "Role Based Access Control." https://csrc.nist.gov/projects/role-based-access-control ↩
-
NIST. "Least Privilege." NIST Glossary. https://csrc.nist.gov/glossary/term/least_privilege ↩
-
Cloudflare. "What is Mutual TLS (mTLS)?" https://www.cloudflare.com/learning/access-management/what-is-mutual-tls/ ↩ ↩2
-
OASIS. "Security Assertion Markup Language (SAML) V2.0." https://wiki.oasis-open.org/security/FrontPage ↩
-
Biryukov, A., Dinu, D., and Khovratovich, D. "Argon2." https://github.com/P-H-C/phc-winner-argon2 ↩
-
Password Hashing Competition. https://www.password-hashing.net/ ↩
-
Wikipedia. "bcrypt." https://en.wikipedia.org/wiki/Bcrypt ↩
-
Percival, C. "scrypt." https://www.tarsnap.com/scrypt.html ↩
-
OWASP. "Password Storage Cheat Sheet." https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html ↩
-
Grassi, P. et al. (2017). "Digital Identity Guidelines: Authentication and Lifecycle Management." NIST Special Publication 800-63B. https://pages.nist.gov/800-63-3/sp800-63b.html ↩ ↩2
-
M'Raihi, D. et al. (2011). "TOTP: Time-Based One-Time Password Algorithm." RFC 6238, IETF. https://datatracker.ietf.org/doc/html/rfc6238 ↩
-
Google. "Google Authenticator." https://support.google.com/accounts/answer/1066447 ↩
-
Twilio. "Authy." https://authy.com/ ↩
-
AgileBits. "1Password." https://1password.com/ ↩
-
M'Raihi, D. et al. (2005). "HOTP: An HMAC-Based One-Time Password Algorithm." RFC 4226, IETF. https://datatracker.ietf.org/doc/html/rfc4226 ↩
-
FBI. "SIM Swapping." https://www.fbi.gov/how-we-can-help-you/safety-resources/scams-and-safety/common-scams-and-crimes/sim-swapping ↩
-
Wired. (2016). "The Critical Hole at the Heart of Cell Phone Infrastructure." https://www.wired.com/2016/04/the-critical-hole-at-the-heart-of-cell-phone-infrastructure/ ↩
-
Crosby, S. (2009). "A Lesson In Timing Attacks." https://codahale.com/a-lesson-in-timing-attacks/ ↩
-
Charles Proxy. "Web Debugging Proxy." https://www.charlesproxy.com/ ↩
-
mitmproxy. "A free and open source interactive HTTPS proxy." https://mitmproxy.org/ ↩
-
Mozilla. "Server Side TLS." https://wiki.mozilla.org/Security/Server_Side_TLS ↩
-
Auth0. "JWT.io." https://jwt.io/ ↩
-
Auth0. (2015). "Critical vulnerabilities in JSON Web Token libraries." https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/ ↩
-
CVE. "CVE-2015-9235." https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2015-9235 ↩
-
Redis. "In-memory data structure store." https://redis.io/ ↩ ↩2
-
OWASP. "Cross-site Scripting (XSS)." https://owasp.org/www-community/attacks/xss/ ↩
-
MDN. "SameSite cookies." https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite ↩
-
Lodderstedt, T. et al. "OAuth 2.0 Security Best Current Practice." IETF Draft. https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics ↩
-
Jones, M., Bradley, J., and Sakimura, N. (2015). "JSON Web Signature (JWS)." RFC 7515, IETF. https://datatracker.ietf.org/doc/html/rfc7515 ↩
-
Jones, M. and Hildebrand, J. (2015). "JSON Web Encryption (JWE)." RFC 7516, IETF. https://datatracker.ietf.org/doc/html/rfc7516 ↩
-
Microsoft. "Azure Active Directory." https://azure.microsoft.com/en-us/services/active-directory/ ↩
-
Keycloak. "Open Source Identity and Access Management." https://www.keycloak.org/ ↩
-
Okta. "Identity and Access Management." https://www.okta.com/ ↩
-
Auth0. "Identity Platform." https://auth0.com/ ↩
-
HashiCorp. "Vault - Manage Secrets and Protect Sensitive Data." https://www.vaultproject.io/ ↩
-
CyberArk. "Privileged Access Management." https://www.cyberark.com/ ↩
-
AWS. "AWS Secrets Manager." https://aws.amazon.com/secrets-manager/ ↩
-
Polli, R. and Martinez, A. "RateLimit Header Fields for HTTP." IETF Draft. https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/ ↩
-
Nottingham, M. and Fielding, R. (2012). "Additional HTTP Status Codes." RFC 6585, IETF. https://datatracker.ietf.org/doc/html/rfc6585 ↩
-
Fingerprint. "FingerprintJS." https://fingerprint.com/ ↩
-
Tor Project. "Anonymity Online." https://www.torproject.org/ ↩
-
European Union. "General Data Protection Regulation (GDPR)." https://gdpr.eu/ ↩
-
OWASP. "Session Management Cheat Sheet." https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html ↩
-
FIDO Alliance. "Passkeys." https://fidoalliance.org/passkeys/ ↩
-
NIST. (2024). "Digital Identity Guidelines." NIST SP 800-63-4. https://pages.nist.gov/800-63-4/ ↩
-
OWASP. "API Security Top 10 2023 - API1:2023 Broken Object Level Authorization." https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/ ↩
-
Fett, D. et al. (2023). "OAuth 2.0 Demonstrating Proof of Possession (DPoP)." RFC 9449, IETF. https://datatracker.ietf.org/doc/html/rfc9449 ↩
-
Newman, S. (2015). "Pattern: Backends For Frontends." https://samnewman.io/patterns/architectural/bff/ ↩
-
Hodges, J., Jackson, C., and Barth, A. (2012). "HTTP Strict Transport Security (HSTS)." RFC 6797, IETF. https://datatracker.ietf.org/doc/html/rfc6797 ↩
-
W3C. "Content Security Policy Level 3." https://www.w3.org/TR/CSP3/ ↩
-
Mozilla. "Mozilla Observatory." https://observatory.mozilla.org ↩
-
OWASP. "OWASP API Security Top 10 (2023)." https://owasp.org/API-Security/ ↩