JWT alg:none Bypass — Why Your Token Validation Is Broken
A 72-hour breach from one JWT header field.
20+ years shipping large-scale distributed systems. Notes here come from systems that actually shipped.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- API security protects endpoints from unauthorized access, injection, replay attacks, and denial-of-service — defense in depth across auth, rate limiting, validation, and monitoring
- Key components: OAuth 2.0 + JWT (auth), rate limiting (DoS protection), input validation (injection), TLS (encryption), audit logging (forensics)
- Performance impact: JWT validation adds 2-5ms, rate limiting adds 1-3ms Redis round-trip, input validation ~1ms
- Production trap: JWT with alg:none accepted by misconfigured library — attacker bypasses signature verification entirely
- Biggest mistake: Trusting user input without validation — SQL injection, XSS, and NoSQL injection start at the API boundary
JWT alg:none is a critical vulnerability in JSON Web Token validation where an attacker crafts a token with the algorithm field set to none, bypassing signature verification entirely. This exploit exists because some JWT libraries, when misconfigured, accept unsigned tokens as valid, treating alg:none as a legitimate algorithm rather than rejecting it outright.
The problem stems from a fundamental design flaw in early JWT implementations: developers often validate the token's signature but fail to enforce that a signature actually exists. If your server-side code doesn't explicitly check that the alg header is a trusted, signed algorithm (like HS256 or RS256), an attacker can forge tokens with arbitrary claims—e.g., setting sub: admin—and gain unauthorized access to protected resources.
This isn't a theoretical attack; it's been exploited in production systems at scale, including critical vulnerabilities in major platforms like Auth0's legacy libraries and countless custom API implementations. The fix is straightforward: always validate the algorithm against a whitelist and reject none outright, but the prevalence of this bug underscores how easy it is to get token validation wrong when you treat JWT as a black box rather than understanding its internals.
Imagine your API is a bank vault. The vault has a door (authentication), a guard who checks your ID (authorization), a camera watching for suspicious behavior (rate limiting), and a rule that says nobody can slip a fake deposit slip through the slot (input validation). API security is the complete system of locks, guards, and alarms — not just the front door. Miss any one piece and the whole vault is compromised.
Every second, APIs are being probed, fuzzed, replayed, and abused at scale. The 2023 Salt Security API Security report found that 94% of organizations experienced security problems in production APIs. The OWASP API Security Top 10 reads like a greatest-hits album of real breaches — from the Peloton user data leak (broken object-level authorization) to the Twitter 5.4M account scrape (broken function-level authorization). APIs are the attack surface that never sleeps.
The core problem is that APIs are designed for machine-to-machine communication, which means they're verbose, consistent, and predictable — all properties that attackers love. A human logging into a web app triggers a CAPTCHA, gets rate-limited by IP, and shows up in session logs. An automated script hammering your REST API at 10,000 requests per second looks identical to a legitimate integration partner unless you've built defense in depth from day one.
By the end you'll understand not just the what but the why behind every major API security control. You'll be able to threat-model an API surface, implement JWT validation correctly (including the alg:none exploit), design a rate limiter that survives distributed attackers, and build input validation that stops injection attacks before they reach your database. This is the article you bring to your next architecture review.
Why JWT alg:none Is a Critical Validation Gap
JWT alg:none bypass is a token validation flaw where an attacker crafts a JWT with the algorithm header set to 'none', tricking a server that fails to enforce algorithm verification into accepting unsigned tokens as valid. The core mechanic: the server's JWT library checks the signature only when the algorithm is explicitly set to a signing algorithm; if 'none' is allowed, the library skips signature verification entirely. This turns a stateless authentication token into a trivially forgeable credential.
In practice, this vulnerability arises when developers use JWT libraries with default configurations that accept 'none' or when custom validation logic checks the algorithm header after parsing but before verification. The attack works because the JWT specification requires servers to reject tokens with 'alg:none' unless the token is sent over a secure channel — a condition rarely met in web APIs. Attackers exploit this by sending a token with a modified header and payload, signed with nothing, and the server accepts it as authentic.
You must use this knowledge to audit every JWT validation pipeline: explicitly whitelist allowed algorithms (e.g., RS256, HS256) and reject any token with 'alg:none' or an unrecognized algorithm. This matters because a single misconfigured library or missing validation step can expose your entire API to unauthorized access — no brute-forcing required.
Authentication and Authorization — The Two Doors
Authentication (AuthN) verifies who you are — your identity. Authorization (AuthZ) verifies what you're allowed to do — your permissions. They are not the same thing, and confusing them leads to broken access control, the #1 OWASP API risk.
JWT (JSON Web Tokens) are the most common AuthN mechanism for APIs. A JWT contains a header (algorithm), payload (claims like sub, exp, roles), and signature. The signature prevents tampering — but only if you validate it correctly.
The critical JWT validation steps are: verify the signature using the correct algorithm and key; check the expiration (exp) claim is in the future; check the not-before (nbf) claim is in the past; validate the issuer (iss) if your API only trusts one issuer; and crucially, reject the 'none' algorithm. Many JWT libraries accept 'alg':'none' for backward compatibility. An attacker can change the algorithm to 'none', remove the signature, and the library will accept the token as valid.
OAuth 2.0 is the framework for authorization delegation — letting a third-party access your API on behalf of a user without seeing their password. It uses grants: authorization code (most common, for web apps), client credentials (machine-to-machine), and refresh tokens (long-lived access renewal). The access token (often a JWT) is short-lived; the refresh token is long-lived and stored securely by the client.
package io.thecodeforge.api; import com.auth0.jwt.JWT; import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.interfaces.DecodedJWT; import java.security.interfaces.RSAPublicKey; /** * Production JWT validation — explicit algorithms, no defaults, no alg:none. * The single most common security failure in API production incidents * is accepting tokens with alg:none due to library defaults. */ public class JwtValidation { // ─── SYMMETRIC KEY (HS256) — single secret shared between issuer and validator // Only use when issuer and validator are the same service. // Secret must be at least 32 bytes (256 bits) for HS256. public static DecodedJWT validateSymmetric(String token, String secret) { Algorithm algorithm = Algorithm.HMAC256(secret); JWTVerifier verifier = JWT.require(algorithm) .acceptLeeway(60) // 60 seconds clock skew tolerance .withIssuer("auth.thecodeforge.io") // Restrict to trusted issuer .build(); // Throws JWTVerificationException on failure — signature, expiry, issuer mismatch return verifier.verify(token); } // ─── ASYMMETRIC KEY (RS256) — public key for validation, private key for signing // Preferred for microservices: validation service only needs public key. // Compromised validation service cannot forge new tokens (needs private key). public static DecodedJWT validateAsymmetric(String token, RSAPublicKey publicKey) { Algorithm algorithm = Algorithm.RSA256(publicKey, null); JWTVerifier verifier = JWT.require(algorithm) .acceptLeeway(60) .withIssuer("auth.thecodeforge.io") .build(); return verifier.verify(token); } // ─── EXPLICITLY REJECT ALG:NONE — the most dangerous JWT misconfiguration // Attackers send {"alg":"none","typ":"JWT"} with empty signature. // Libraries that don't explicitly reject it skip verification entirely. public static void rejectAlgNone(String tokenHeader) { if (tokenHeader.contains("\"alg\":\"none\"")) { throw new SecurityException("alg:none tokens are never accepted — possible attack"); } } // ─── Extract claims after validation — never before! // Claims from an unverified token are attacker-controlled and MUST NOT be trusted. public static void processClaims(DecodedJWT verifiedToken) { String userId = verifiedToken.getSubject(); String role = verifiedToken.getClaim("role").asString(); // Only after verification — these claims are now trusted System.out.printf("Authenticated user: %s with role: %s%n", userId, role); } }
JWT.require(Algorithm.HMAC256(secret)) NOT JWT.require(algorithm) with algorithm from the token header./.well-known/jwks.json.Rate Limiting — Protecting APIs from Abuse and DoS
Rate limiting is the shield between your API and a DDoS attack or a misconfigured client. Without it, a single user or botnet can consume all your database connections, saturate your CPU, or rack up cloud costs.
The core rate limiting strategies are: Fixed window (count requests per minute, reset on the minute) — simple but bursty; Sliding window (count requests in the last 60 seconds) — smoother but more memory-intensive; Token bucket (refill tokens at a fixed rate) — allows bursts up to bucket size; and Leaky bucket — queues requests and processes at a fixed rate.
For distributed systems, rate limit state must be shared across gateway instances. Redis with atomic INCR + EXPIRE is the standard solution. Key format: rate_limit:{userId}:{window_timestamp}. Each request increments the key; if the count exceeds limit, reject.
Attackers bypass naive rate limiters by: spreading requests across multiple IPs (use user ID or API key as the key); spreading across multiple endpoints (use aggregate rate limit per user across all endpoints); or using headers like X-Forwarded-For (validate and normalize IP headers, never trust them directly).
package io.thecodeforge.api; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import redis.clients.jedis.params.SetParams; /** * Production distributed rate limiter using Redis with atomic INCR + EXPIRE. * This works correctly across multiple API gateway instances. * * Key pattern: rate_limit:{userId}:{minute_window} * Window granularity: 60 seconds aligned to wall clock minute. * Trade-off: fixed window allows double-burst at window boundaries. * For sliding window, use sorted sets (ZADD + ZREMRANGEBYSCORE). */ public class RedisRateLimiter { private final JedisPool jedisPool; private final int limitPerMinute; public RedisRateLimiter(String redisHost, int port, int limitPerMinute) { this.jedisPool = new JedisPool(redisHost, port); this.limitPerMinute = limitPerMinute; } /** * Check if request is allowed for this user. * Returns true if under limit, false if rate limited. */ public boolean isAllowed(String userId) { // Fixed window aligned to current minute long currentMinute = System.currentTimeMillis() / 60000; String key = String.format("rate_limit:%s:%d", userId, currentMinute); try (Jedis jedis = jedisPool.getResource()) { // Atomic increment — returns the new count long currentCount = jedis.incr(key); // Set expiry on first increment only (60 seconds + 1 second buffer) if (currentCount == 1) { jedis.expire(key, 61); } return currentCount <= limitPerMinute; } } /** * Sliding window rate limiter using Redis sorted sets. * More accurate but higher memory and CPU overhead. * Use for strict rate limits where fixed window bursts are unacceptable. */ public boolean isAllowedSlidingWindow(String userId, int limitPerMinute) { String key = String.format("rate_limit_sliding:%s", userId); long nowMs = System.currentTimeMillis(); long windowStartMs = nowMs - 60000; // 60 seconds ago try (Jedis jedis = jedisPool.getResource()) { // Remove requests older than 60 seconds jedis.zremrangeByScore(key, 0, windowStartMs); // Count requests in current window long requestCount = jedis.zcard(key); if (requestCount >= limitPerMinute) { return false; // Rate limited } // Add current request with timestamp as score jedis.zadd(key, nowMs, String.format("%d:%s", nowMs, userId + System.nanoTime())); jedis.expire(key, 61); // Auto-cleanup return true; } } }
tokens and last_refill_timestamp. Burst size = bucket capacity.Input Validation — Stopping Injection at the Door
Input validation is the single most cost-effective security control. It's the fence at the top of the cliff, not the ambulance at the bottom. Validate everything, reject anything unexpected, and do it before authentication to save resources.
The types of injection attacks are: SQL injection — attacker sends ' OR '1'='1 to bypass login or extract data; NoSQL injection — operators like $ne and $where injected into MongoDB queries; Command injection — semicolons and pipes to execute arbitrary system commands; and XSS (Cross-Site Scripting) — script tags in user input that execute in browsers.
Validate for type, length, format, and range. A UUID field should reject non-UUID strings early. An email field should reject strings over 254 characters. A status field with enum values should reject arbitrary strings. Use allowlisting (allow known-good patterns) not denylisting (block known-bad patterns) — attackers always find new bad patterns.
Sanitization (escaping) and parametrization are different from validation. Parameterized SQL queries separate code from data, making injection structurally impossible. Input validation rejects malicious input before it reaches the database, but parameterization is the primary defense for SQL injection — validation alone is not sufficient.
package io.thecodeforge.api; import java.util.regex.Pattern; import java.util.UUID; /** * Production input validation — validate early, validate strictly. * All input from clients is attacker-controlled until proven otherwise. * * Key principles: * 1. Validate type, length, format, and range * 2. Use allowlists (known-good patterns), not denylists (known-bad) * 3. Validate before authentication to reject junk early * 4. Parameterize queries — validation is NOT a replacement for prepared statements */ public class InputValidation { // ─── ALLOWLIST PATTERNS — explicit, strict, minimal ────────────────────── private static final Pattern UUID_PATTERN = Pattern.compile("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", Pattern.CASE_INSENSITIVE); private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_]{3,20}$"); // Alphanumeric + underscore, 3-20 chars private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); private static final Pattern ORDER_ID_PATTERN = Pattern.compile("^ORD-[0-9]{10,15}$"); // ORD-1234567890 // ─── Input validation methods — early returns, clear error messages ────── public static boolean isValidUUID(String value) { if (value == null) return false; return UUID_PATTERN.matcher(value).matches(); } public static boolean isValidUsername(String value) { if (value == null) return false; return USERNAME_PATTERN.matcher(value).matches(); } // ─── LENGTH VALIDATION — always enforce maximums, even for legitimate fields // Email spec allows 254 characters total. Most legitimate emails are under 50. // Reject excessively long inputs early to prevent resource exhaustion. public static String validateAndSanitizeEmail(String input) { if (input == null || input.length() > 254) { throw new IllegalArgumentException("Invalid email format or length"); } String trimmed = input.trim(); if (!EMAIL_PATTERN.matcher(trimmed).matches()) { throw new IllegalArgumentException("Invalid email format"); } return trimmed; } // ─── ENUM VALIDATION — reject anything not in the explicit set public enum OrderStatus { PENDING, PROCESSING, SHIPPED, DELIVERED, CANCELLED } public static OrderStatus validateOrderStatus(String input) { if (input == null) throw new IllegalArgumentException("Status is required"); try { return OrderStatus.valueOf(input.toUpperCase()); } catch (IllegalArgumentException e) { throw new IllegalArgumentException("Invalid status: " + input + ". Allowed values: PENDING, PROCESSING, SHIPPED, DELIVERED, CANCELLED"); } } // ─── PARAMETERIZED QUERY — the only real defense against SQL injection // Input validation catches obviously malicious input, but parameterization // is what makes injection structurally impossible. public static void parameterizedQueryExample() { // NEVER do this: // String query = "SELECT * FROM users WHERE id = '" + userId + "'"; // ALWAYS do this: // PreparedStatement stmt = connection.prepareStatement( // "SELECT * FROM users WHERE id = ?" // ); // stmt.setString(1, userId); } }
maxDepth and maxStringLength constraints.Role-Based Access Control (RBAC) — Stop Giving Everyone a Master Key
You wouldn't give a summer intern the root password to prod. But week after week I see APIs where every endpoint trusts the caller implicitly because "they passed auth." That's not security, that's a welcome mat.
RBAC is the principle of least privilege applied to API design. You define roles — admin, editor, viewer — and map them to explicit permission sets. Your API gateway or middleware checks not just "is this user authenticated?" but "does this user's role have the invoice:delete permission?" before allowing the operation.
The WHY is obvious: containment. If an attacker compromises a viewer account, they can't delete records. If a rogue admin goes wild, you revoke the role, not rebuild the database. Implement RBAC at the gateway layer, not in every controller. Centralize the policy decision point so you don't have twenty different auth checks scattered across services, each with their own bugs.
Use enum-based roles stored in your token or session. Never parse roles from user-supplied payloads — that's how you get privilege escalation straight out of OWASP's top ten.
// io.thecodeforge — system-design tutorial from flask import request, jsonify from functools import wraps # Centralized permission map — single source of truth PERMISSIONS = { "admin": {"read", "write", "delete"}, "editor": {"read", "write"}, "viewer": {"read"} } def require_permission(needed_perm): def decorator(f): @wraps(f) def wrapper(*args, **kwargs): # Extract role from JWT — NEVER from request body user_role = request.jwt_payload.get("role", "viewer") user_perms = PERMISSIONS.get(user_role, set()) if needed_perm not in user_perms: return jsonify({"error": "insufficient permissions"}), 403 return f(*args, **kwargs) return wrapper return decorator @app.route("/api/invoices/<id>", methods=["DELETE"]) @require_permission("delete") def delete_invoice(id): # Delete logic — auth already handled return jsonify({"deleted": id})
API Gateways with WAF Integration — Your First Line of Defense Isn't Your Code
Your application code shouldn't be the first thing that sees a malicious request. That's like letting strangers walk through your front door before checking if they're carrying a crowbar. An API gateway with a Web Application Firewall (WAF) is your bouncer.
A WAF inspects incoming traffic at layer 7 — HTTP headers, query parameters, request bodies — and blocks patterns that match known attack signatures: SQL injection attempts, XSS payloads, path traversal, and mass assignment exploits. It's not a replacement for input validation; it's a pre-filter that eats the shotgun blast so your validation logic only sees clean rounds.
Integrate it with rate limiting at the gateway layer. Block an IP after 100 requests per second? That's the WAF's job. Correlate unusual request patterns — like a single client hitting every GET /api/users/{id} endpoint in sequence — and shunt them to a slow queue or drop them entirely. Your upstream services should never have to think about volumetric attacks.
The critical nuance: Don't treat your WAF rules as static. Attackers evolve. Subscribe to managed rule sets (AWS WAF Managed Rules, Cloudflare OWASP CRS) that auto-update. And for god's sake, log WAF blocks to a separate stream — you'll need them for post-mortem analysis when something inevitably slips through.
// io.thecodeforge — system-design tutorial import redis import time # Simple token-bucket rate limiter at gateway level RATE_LIMIT = 100 BURST_LIMIT = 150 redis_client = redis.Redis(host="gateway-cache", port=6379, decode_responses=True) def rate_limit_and_waf_check(request): client_ip = request.headers.get("X-Forwarded-For", "unknown") current_tokens = redis_client.get(f"rate_limit:{client_ip}") if current_tokens is None: redis_client.setex(f"rate_limit:{client_ip}", 1, BURST_LIMIT) current_tokens = BURST_LIMIT elif int(current_tokens) <= 0: log_block(client_ip, "rate_limit_exceeded") return False # 429 Too Many Requests # WAF signature check (simplified) if "DROP TABLE" in request.data.decode().upper(): log_block(client_ip, "sql_injection_attempt") return False # 403 Forbidden redis_client.decr(f"rate_limit:{client_ip}") return True
Regular Audits and Penetration Testing — Assume You're Already Breached
Your code is not safe. You have bugs. You have misconfigurations. That's not pessimism — that's engineering reality. The only question is whether you find them before someone else does. Regular audits and penetration testing flip the script: instead of hoping your defenses hold, you actively try to break them.
Penetration testing is not a checkbox exercise. It's a tactical recon mission. You pay experts to think like attackers — SQL injection, broken object-level authorization, JWT manipulation, race conditions. They will find things your unit tests never dreamed of. Combine annual third-party tests with quarterly internal red teams.
Automated scanning catches the low-hanging fruit: outdated libraries, exposed endpoints, missing headers. But automation misses business logic flaws. That's where manual testing earns its keep. Run audits after every major release. Document findings. Fix them before shipping. Treat every vulnerability as a production incident, because for your users, it is.
// io.thecodeforge — system-design tutorial import subprocess from datetime import datetime def run_audit(): print(f"[+] Security audit started: {datetime.utcnow()}") # Dependency scan subprocess.run(["safety", "check", "--full-report"]) # Static analysis subprocess.run(["bandit", "-r", "src/", "-f", "json", "-o", "audit_report.json"]) # API endpoint discovery endpoints = ["/api/v1/users", "/api/v1/orders", "/admin/panel"] for ep in endpoints: result = subprocess.run(["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", f"http://localhost:5000{ep}"], capture_output=True, text=True) print(f" Endpoint {ep} -> HTTP {result.stdout}") if __name__ == "__main__": run_audit()
Best Practices for API Security — Stop Making the Same Mistakes
Security isn't a feature you bolt on in QA. It's a constraint you bake into your architecture from day one. Here are the non-negotiables: use HTTPS everywhere — not just login pages. Enforce TLS 1.2 minimum. Implement API keys for machine-to-machine traffic, and treat them like passwords: rotate quarterly, revoke immediately on compromise.
Validation is your first wall. Whitelist inputs, don't blacklist. Reject anything that doesn't match your schema. SQL injection is still the #1 attack vector because devs still concatenate queries. Stop that. Use parameterized queries or an ORM. For REST, enforce HTTP method semantics — GET never mutates state, POST creates, PUT replaces, DELETE destroys.
Log everything that's suspicious. 401s from unknown API keys. Rapid-fire requests. Requests hitting endpoints that don't exist. Ship those logs to a SIEM. Alert on anomalies. And please — never, ever hardcode secrets. Use environment variables. Use a vault. Your .env file is not production-ready.
// io.thecodeforge — system-design tutorial import re from flask import request, abort def validate_api_key(): api_key = request.headers.get("X-API-Key", "") if not re.match(r"^[a-f0-9]{32}$", api_key): abort(401, description="Invalid API key format") # Check against vault (not shown for brevity) if api_key not in get_valid_keys(): abort(403, description="Forbidden") def enforce_http_methods(): # Reject DELETE on non-deletable resources if request.method == "DELETE" and "/orders/" not in request.path: abort(405, description="Method not allowed") def sanitize_input(): # Whitelist allowed characters if request.json: for key, value in request.json.items(): if not re.match(r"^[a-zA-Z0-9_.-]+$", str(value)): abort(400, description=f"Invalid input: {key}")
The JWT alg:none Exploit That Bypassed Authentication
{"alg":"none"} in the header and an empty signature. The library saw alg:none, skipped signature verification entirely, and considered the token valid. The attacker could set any payload — including {"sub":"admin","role":"administrator"} — and the API would accept it. The breach continued for 72 hours until the anomaly in token size alerted the team.JWT.require(Algorithm.HMAC256(secret)).acceptLeeway(60).build().verify(token); for symmetric keys, or Algorithm.RSA256(publicKey, null) for asymmetric. Reject any token with alg:none before parsing. Added monitoring for tokens with unusual header structures. Rotated all secrets and forced password resets for affected users.- Never rely on library defaults for JWT validation. Explicitly specify allowed algorithms — and never include 'none'.
- JWT validation is not 'set and forget'. Test validation logic with malformed tokens in CI: alg:none, missing signature, expired timestamps.
- Monitor for tokens with unusual alg values. A token with alg:none is always an attack attempt — alert immediately.
- Use asymmetric signing (RS256) for microservices. The validation service only needs the public key; compromise doesn't leak the signing key.
setAllowedClockSkew(120) in JWT validation. Also check for multiple JWT validation libraries — they may have different leeway defaults.if (requestedUserId !== token.sub && !token.roles.includes('admin')) { return 403; }Access-Control-Allow-Origin header — never use * with credentials. For development, use specific origin https://app.example.com. For multiple origins, inspect the Origin header and echo it back if allowed, or use a regex whitelist. Add Access-Control-Allow-Credentials: true if using cookies.grep -r 'Bearer' /var/log/api/* | grep -E 'alg.{0,5}none'echo 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIn0.' | jwt decode -curl -H 'Authorization: Bearer user123-token' https://api.example.com/users/456/profilegrep -r 'req.params.id\|req.query.userId' routes/ | head -10requestedUserId with token.sub before allowing access. Reject with 403 Forbidden if mismatch and user is not admin.curl -X POST https://api.example.com/search -d '{"query":"test'"}' -H 'Content-Type: application/json'grep -r 'execute\b.*\+.*req\.' services/db.query('SELECT * FROM users WHERE id = $1', [userId]). Add WAF rule blocking SQL metacharacters as temporary mitigation.kubectl get pods -l app=gateway | wc -lredis-cli KEYS 'rate_limit:*' | wc -lcurl -I http://api.example.com/health 2>&1 | head -5ss -tln | grep :443Strict-Transport-Security: max-age=31536000; includeSubDomains. Redirect all HTTP to HTTPS at load balancer level.| Aspect | Authentication (AuthN) | Authorization (AuthZ) |
|---|---|---|
| Purpose | Verifies identity — who is making the request | Verifies permissions — what they are allowed to do |
| Question answered | "Are you who you say you are?" | "Are you allowed to do this?" |
| Typical mechanism | JWT validation, OAuth token, API key, username/password | Role-Based Access Control (RBAC), Access Control Lists (ACL), Policy-Based (ABAC) |
| Where it runs | API gateway or auth middleware — once per request | Per service or per endpoint — after authentication |
| Failure consequence | 401 Unauthorized — request rejected, cannot proceed | 403 Forbidden — authenticated but not allowed |
| Common breach | alg:none JWT bypass — attacker forges identity | BOLA (Broken Object Level Authorization) — user accesses another user's data |
| Production check | Verify signature, exp, issuer, algorithm — reject alg:none | Enforce ownership: does token.sub own requested resource ID? |
| File | Command / Code | Purpose |
|---|---|---|
| io | /** | Authentication and Authorization |
| io | /** | Rate Limiting |
| io | /** | Input Validation |
| RbacMiddleware.py | from flask import request, jsonify | Role-Based Access Control (RBAC) |
| WafRateLimitIntegration.py | RATE_LIMIT = 100 | API Gateways with WAF Integration |
| audit_scanner.py | from datetime import datetime | Regular Audits and Penetration Testing |
| middleware.py | from flask import request, abort | Best Practices for API Security |
Key takeaways
Common mistakes to avoid
5 patternsAccepting JWT with alg:none due to library default
JWT.require(Algorithm.HMAC256(secret)) NOT JWT.require(algorithm) where algorithm comes from token header. Reject tokens with alg:none before parsing.Broken Object Level Authorization (BOLA)
/users/123/profile and change the ID to /users/456/profile to view another user's data. API validates JWT but never checks if requested ID belongs to the authenticated user.requestedUserId from path/query with token.sub claim. Only allow access if they match or if the authenticated user has admin role. Never rely on 'security through obscurity' of hard-to-guess IDs.In-memory rate limiting across multiple gateway instances
rate_limit:{userId}:{minute_window}. All gateway instances check the same Redis key. Test with multiple instances in staging before production.Trusting X-Forwarded-For headers for rate limiting without validation
X-Forwarded-For: 1.2.3.4, 5.6.7.8, 9.10.11.12 — the rate limiter keys on the client-supplied value, not the real IP. Attacker cycles through millions of fake IPs to bypass limits.X-Real-IP set by trusted proxy.String concatenation in SQL queries
' OR '1'='1 turns SELECT FROM users WHERE id = ' + userId + ' into SELECT FROM users WHERE id = '' OR '1'='1' — returns all users, bypassing authentication.Interview Questions on This Topic
Explain the difference between authentication and authorization — and give a real example where an API fails at one but not the other.
/users/456/profile to view Bob's data. AuthN succeeded (Alice is who she says she is), but AuthZ failed (Alice is not allowed to access Bob's data).Walk me through how you would secure a REST API that accepts user-generated content (UGC) like comments or reviews.
You discover that your API is vulnerable to injection attacks. What immediate steps do you take, and what's your long-term fix?
Compare JWT with opaque reference tokens. When would you choose one over the other?
Frequently Asked Questions
JWT is a token format — a signed JSON payload that contains claims. OAuth 2.0 is an authorization framework — a set of flows (grants) for how tokens are issued and used. OAuth 2.0 often uses JWTs as the token format, but they are orthogonal concepts. You can have OAuth 2.0 with opaque tokens (no JWT) and JWTs without OAuth 2.0 (simple API key alternative). In practice, most modern APIs use OAuth 2.0 with JWTs as access tokens.
Almost never. Basic Auth sends credentials (username:password base64 encoded) with every request. Without HTTPS, it's completely insecure. Even with HTTPS, credentials are cached on the client, rotated infrequently, and every request carries them. Use OAuth 2.0 with short-lived JWTs or API keys with automatic rotation. Basic Auth is acceptable only for internal machine-to-machine communication over a hardened network, and even then, mTLS or client certificates are better.
Log in as User A, and log in as User B in another session. For every endpoint that takes an object ID as a parameter (userId, orderId, documentId), call it with User A's token but User B's ID. If you can access User B's data, BOLA is present. Automate this in your integration tests: create two test users, iterate across endpoints, assert that User A cannot access User B's resources. The BOLA vulnerability is the most common API security failure and the easiest to test with automated scripts.
Rate limiting is per-client, per-endpoint, configurable by user. It protects against misconfigured clients and abusive users (4,000 requests per second from one API key). DDoS protection operates at the network and transport layers — SYN flood mitigation, connection limits, geographic filtering, and traffic scrubbing. Use both: DDoS protection at the cloud load balancer (AWS Shield, Cloudflare) to filter volumetric attacks, and rate limiting at the API gateway to control behaviour per user. A DDoS attack may overwhelm DDoS protection, but rate limiting alone won't stop a million-bot botnet because each bot stays under its individual limit.
20+ years shipping large-scale distributed systems. Notes here come from systems that actually shipped.
That's Security. Mark it forged?
6 min read · try the examples if you haven't