Senior 8 min · March 06, 2026
API Security Best Practices

JWT alg:none Bypass — Why Your Token Validation Is Broken

A 72-hour breach from one JWT header field.

N
Naren Founder & Principal Engineer

20+ years shipping large-scale distributed systems. Notes here come from systems that actually shipped.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
✦ Definition~90s read
What is API Security?

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.

Imagine your API is a bank vault.

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.

Plain-English First

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.

Default Configurations Are Dangerous
Many JWT libraries (e.g., jjwt, nimbus-jose-jwt) historically allowed 'none' by default. Always set a strict algorithm whitelist explicitly in code.
Production Insight
A fintech API used a JWT library that defaulted to accepting 'none' for development convenience. In production, an attacker sent a token with alg:none and admin claims, gaining full account takeover. The symptom was a 200 OK on a protected endpoint with no signature validation. Rule: never trust library defaults — explicitly enforce algorithm whitelist in every environment.
Key Takeaway
Never accept 'alg:none' in any environment — not even for testing.
Always whitelist allowed algorithms (e.g., RS256, HS256) before parsing the token.
Validate the algorithm header before signature verification, not after.
JWT alg:none Bypass — Token Validation Gap THECODEFORGE.IO JWT alg:none Bypass — Token Validation Gap Flow from token entry to secure API with RBAC and WAF JWT Token Entry alg:none header accepted Authentication Bypass No signature verification Authorization Failure RBAC not enforced Rate Limiting & Input Validation Prevent abuse and injection API Gateway with WAF First line of defense Secure API Output Audited and hardened ⚠ alg:none bypasses signature check entirely Always validate algorithm and reject 'none' THECODEFORGE.IO
thecodeforge.io
JWT alg:none Bypass — Token Validation Gap
Api Security Best Practices

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.

io/thecodeforge/api/JwtValidation.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
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);
    }
}
The JWT alg:none Vulnerability — Still Working in 2026
The 'none' algorithm vulnerability was discovered in 2015. In 2026, libraries still accept it by default if you don't explicitly restrict algorithms. Attackers scan for this vulnerability constantly — it's the easiest way to bypass authentication completely. Always set allowed algorithms explicitly: JWT.require(Algorithm.HMAC256(secret)) NOT JWT.require(algorithm) with algorithm from the token header.
Production Insight
JWT validation without explicit algorithm restriction is a critical vulnerability. Attackers send alg:none and bypass signature checks entirely.
The library's default may include 'none'. Always specify allowed algorithms in your validation code.
Rule: Never trust JWT claims before signature verification. Validate first, then extract claims. An unverified token is attacker-controlled input.
Key Takeaway
Authentication verifies identity; authorization verifies permissions. Never confuse them.
JWT validation must explicitly reject alg:none and specify allowed algorithms — never rely on defaults.
Rule: Validate the JWT signature before extracting a single claim. Unverified claims are attacker-controlled input and cannot be trusted.
JWT Algorithm Selection
IfSingle service issues and validates tokens (monolith, BFF)
UseUse HS256 (symmetric). Store secret in secrets manager, rotate quarterly. Simpler, faster, no key management complexity.
IfMultiple microservices validate tokens, one auth service issues them
UseUse RS256 (asymmetric). Auth service holds private key; each validation service only needs public key. Compromised validation service can't forge tokens.
IfTokens must be validated by third-party services you don't control
UseUse RS256 with public key distribution via JWKS endpoint. Third party fetches public key from /.well-known/jwks.json.
IfTokens have large payloads (>4KB) causing header size issues
UseUse reference tokens (opaque string) stored in Redis with JWT as the reference. Trade-off: validation service must call auth service for each request.
IfYou need to revoke specific tokens before expiry
UseUse short-lived JWTs (5-15 minutes) plus refresh tokens. For revocation, maintain a denylist in Redis checked during validation.

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

io/thecodeforge/api/RedisRateLimiter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
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;
        }
    }
}
Rate Limiting vs Throttling
Rate limiting rejects requests that exceed the limit (returns 429). Throttling queues or slows down requests instead of rejecting them. Use rate limiting for public APIs to prevent abuse. Use throttling for internal services where occasional latency spikes are better than dropping requests entirely. Throttling requires more careful design to avoid queue buildup.
Production Insight
In-memory rate limiters fail silently in distributed environments. Each gateway instance has its own counter, so users get effectively multiplied limits.
Redis with atomic INCR + EXPIRE is the standard solution for shared rate limit state.
Rule: If you have more than one gateway instance (which you must for HA), your rate limiter requires a shared store. Add Redis before you scale, not after.
Key Takeaway
Rate limiting without distributed state is a false sense of security. Two gateway instances = double the limit.
Redis INCR + EXPIRE with atomic operations is the production standard for shared rate limit state.
Rule: Key rate limits on user ID or API key, not IP address — attackers can spread across IPs to bypass IP-based limits.
Rate Limiting Strategy Selection
IfSimple use case, single gateway instance, low traffic (< 10k RPS)
UseFixed window in-memory counter. No external dependencies. Budget = limit per window, resets on the minute.
IfDistributed gateways (HA or scaling), higher traffic
UseFixed window with Redis INCR + EXPIRE. Atomic, shared across instances. Trade-off: double bursts at window boundaries.
IfStrict limits where window-boundary bursts are unacceptable (financial, regulatory)
UseSliding window with Redis sorted sets (ZADD + ZREMRANGEBYSCORE). Higher overhead but exact.
IfNeed to allow bursts while maintaining average rate
UseToken bucket with Redis storing tokens and last_refill_timestamp. Burst size = bucket capacity.
IfDistributed attackers using multiple IPs (botnets)
UseKey on user ID or API key, not IP. For unauthenticated endpoints, use fingerprinting (combined IP + User-Agent) + challenge CAPTCHA on high request rates.

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.

io/thecodeforge/api/InputValidation.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
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);
    }
}
Validate Before Authentication
Run input validation BEFORE authentication checks. A malformed request that fails validation shouldn't waste your auth service's resources. Attackers will probe with thousands of malformed requests — rejecting them at the validation layer saves significant compute. The only exception: validating authentication credentials themselves (username/password format) must happen after auth, but payload validation happens before.
Production Insight
Input validation is not a replacement for parameterized queries. Attackers can bypass validation but parameterization stops them structurally.
Never concatenate user input into SQL, NoSQL, or shell commands — use parameterized APIs or an object mapper.
Rule: Validate for safety (reject malicious patterns), parameterize for security (make injection impossible). Both are necessary; neither alone is sufficient.
Key Takeaway
Input validation rejects malicious input early — the fence at the top of the cliff.
Parameterized queries make SQL injection structurally impossible — the safety net below.
Rule: Validate type, length, format, and range. Use allowlists, not denylists. Attackers always find new bad patterns.
Input Validation Strategy
IfField type is UUID, enum, or has fixed format
UseUse allowlist regex validation at API boundary. Reject before hitting business logic. Return 400 with clear error.
IfField is free text (user bio, comment, review)
UseValidate maximum length only (e.g., 10,000 chars). Escape on output for HTML display. Do NOT parse or interpret content.
IfInput is SQL parameter (ID, name for WHERE clause)
UseValidate type and length, then use parameterized query. Validation alone is insufficient — parameterization prevents injection.
IfInput is used in system command or file path
UseAvoid entirely. If unavoidable, validate with extreme strict allowlist (alphanumeric only) and use allowlist of safe commands.
IfInput includes JSON, XML, or serialized objects
UseParse with secure parser that limits depth, size, and prevents billion-laughs attacks. Use 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.

RbacMiddleware.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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})
Output
GET /api/invoices/123 -> 200 OK (viewer can read)
DELETE /api/invoices/123 -> 403 insufficient permissions (viewer can't delete)
Production Trap:
Don't store roles in a database table that your API can modify. Roles are configuration, not user data. If a user can update their own role via an API call, you've defeated the entire RBAC model.
Key Takeaway
Auth tells you who they are. RBAC tells you what they're allowed to break.

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.

WafRateLimitIntegration.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 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
Output
User sends 151 requests in 1 second -> 429 Too Many Requests
Request contains 'DROP TABLE users' -> 403 Forbidden (blocked by WAF)
Legitimate requests pass through with X-Request-Id for tracing
Senior Shortcut:
Configure your gateway to return a generic '403 Forbidden' for WAF blocks — never reveal whether it was the rate limiter, the signature match, or the behavioral analysis that triggered the block. If attackers know what got them, they'll work around it.
Key Takeaway
A WAF isn't optional. It's the moat around your castle. Your code is the keep.

Regular Audits and Penetration Testing — Assume You're Already Breached

Your code is not safe. You have bugs. You have misconfigurations. That&#x27;s not pessimism — that&#x27;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&#x27;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&#x27;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.

audit_scanner.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 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()
Output
[+] Security audit started: 2025-04-08 14:30:22
Endpoint /api/v1/users -> HTTP 200
Endpoint /api/v1/orders -> HTTP 401
Endpoint /admin/panel -> HTTP 403
Production Trap:
Don&#x27;t give attackers a free pass — never run penetration tests against production without a written authorization letter and a rollback plan. One rogue test can delete user data.
Key Takeaway
Test your API like an attacker, not a developer. Schedule a penetration test before every major release.

Best Practices for API Security — Stop Making the Same Mistakes

Security isn&#x27;t a feature you bolt on in QA. It&#x27;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&#x27;t blacklist. Reject anything that doesn&#x27;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&#x27;s suspicious. 401s from unknown API keys. Rapid-fire requests. Requests hitting endpoints that don&#x27;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.

middleware.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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}")
Output
No output — runs as Flask middleware. On invalid input: HTTP 400 with "Invalid input: username"
Senior Shortcut:
Use an API gateway (Kong, AWS API Gateway) to enforce TLS, rate limiting, and API key validation before your application code ever sees a request.
Key Takeaway
Security is not a checklist. Validate everything, encrypt everything, log everything suspicious.
● Production incidentPOST-MORTEMseverity: high

The JWT alg:none Exploit That Bypassed Authentication

Symptom
API started returning sensitive data for requests that appeared unauthenticated. Logs showed requests with malformed JWTs succeeding. No signature verification failures in logs. A junior engineer noticed that changing a single character in the token still passed validation.
Assumption
The team assumed their JWT library verified signatures by default. They didn't know that some libraries accept the 'none' algorithm for backward compatibility, and they never explicitly restricted allowed algorithms. The default configuration allowed 'none' alongside 'HS256' and 'RS256'.
Root cause
The JWT validation code used a generic library method that accepted any algorithm. An attacker sent a token with {"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.
Fix
Explicitly set allowed algorithms in JWT validation: 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.
Key lesson
  • 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.
Production debug guideSymptom → Action mapping for common API security failures5 entries
Symptom · 01
401 Unauthorized for requests that should be authenticated — but only sometimes
Fix
Check JWT expiration skew. If your server time is ahead of the authorization server's time by > leeway (default 60 seconds), tokens validated as expired. Set setAllowedClockSkew(120) in JWT validation. Also check for multiple JWT validation libraries — they may have different leeway defaults.
Symptom · 02
User can access another user's data by changing ID in URL —/users/123/data accessed by user 456
Fix
Broken Object Level Authorization (BOLA). Your API validates the JWT but never checks if the authenticated user owns or is permitted to access the requested resource ID. Add a middleware that enforces ownership: if (requestedUserId !== token.sub && !token.roles.includes('admin')) { return 403; }
Symptom · 03
Rate limits not applying — user making 1000 req/min when limit is 100
Fix
Check if rate limiter state is shared across instances (use Redis). Verify key includes user ID or client ID, not just IP (attackers behind NAT share IP). Check if limit is per endpoint or global — a user can spread 1000 requests across 10 endpoints, each with limit 100, and never trigger any single limit. Use aggregate rate limiting.
Symptom · 04
SQL injection possible — input with single quote causes database error
Fix
Your API is concatenating user input into SQL queries. Stop using string concatenation immediately. Use parameterized queries (prepared statements) or an ORM that escapes automatically. Run a SAST tool to find all raw SQL concatenation patterns. Add WAF rule that blocks requests with SQL metacharacters as a temporary mitigation.
Symptom · 05
CORS errors — browser can't call API from allowed frontend domain
Fix
CORS is misconfigured. Check 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.
★ API Security Debug Cheat SheetFast diagnostics for production API security issues. Run these at the first sign of a breach.
Suspected JWT alg:none attack
Immediate action
Check logs for tokens with unusual JWT headers or missing signatures
Commands
grep -r 'Bearer' /var/log/api/* | grep -E 'alg.{0,5}none'
echo 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIn0.' | jwt decode -
Fix now
Update JWT validation to explicitly reject alg:none. Rotate all secrets. Force logout all users. Add monitoring for alg:none in WAF.
User accessing another user's data via ID parameter+
Immediate action
Check if API validates ownership of requested resource against authenticated user ID
Commands
curl -H 'Authorization: Bearer user123-token' https://api.example.com/users/456/profile
grep -r 'req.params.id\|req.query.userId' routes/ | head -10
Fix now
Add middleware that compares requestedUserId with token.sub before allowing access. Reject with 403 Forbidden if mismatch and user is not admin.
SQL injection suspected — single quote in input triggers database error+
Immediate action
Check if API uses parameterized queries or string concatenation for SQL
Commands
curl -X POST https://api.example.com/search -d '{"query":"test'"}' -H 'Content-Type: application/json'
grep -r 'execute\b.*\+.*req\.' services/
Fix now
Stop using string concatenation for SQL. Replace with prepared statements: db.query('SELECT * FROM users WHERE id = $1', [userId]). Add WAF rule blocking SQL metacharacters as temporary mitigation.
Rate limit bypassed — user making 3x limit+
Immediate action
Check if multiple gateway instances share rate limit state via Redis
Commands
kubectl get pods -l app=gateway | wc -l
redis-cli KEYS 'rate_limit:*' | wc -l
Fix now
If gateway instances >1 and Redis keys missing, rate limiter is in-memory per instance. Deploy Redis and switch to atomic INCR+EXPIRE operations.
API returning data over HTTP (not HTTPS) in production+
Immediate action
Check if TLS is configured and enforced for all endpoints
Commands
curl -I http://api.example.com/health 2>&1 | head -5
ss -tln | grep :443
Fix now
Configure TLS certificate (Let's Encrypt, cloud provider). Add HSTS header: Strict-Transport-Security: max-age=31536000; includeSubDomains. Redirect all HTTP to HTTPS at load balancer level.
Authentication vs Authorization
AspectAuthentication (AuthN)Authorization (AuthZ)
PurposeVerifies identity — who is making the requestVerifies permissions — what they are allowed to do
Question answered"Are you who you say you are?""Are you allowed to do this?"
Typical mechanismJWT validation, OAuth token, API key, username/passwordRole-Based Access Control (RBAC), Access Control Lists (ACL), Policy-Based (ABAC)
Where it runsAPI gateway or auth middleware — once per requestPer service or per endpoint — after authentication
Failure consequence401 Unauthorized — request rejected, cannot proceed403 Forbidden — authenticated but not allowed
Common breachalg:none JWT bypass — attacker forges identityBOLA (Broken Object Level Authorization) — user accesses another user's data
Production checkVerify signature, exp, issuer, algorithm — reject alg:noneEnforce ownership: does token.sub own requested resource ID?

Key takeaways

1
API security is defense in depth
authentication, authorization, rate limiting, input validation, TLS, and monitoring. No single control is sufficient; the combination creates resilience.
2
JWT validation must explicitly reject alg:none and specify allowed algorithms
never rely on library defaults. Attackers scan for this vulnerability constantly.
3
Rate limiting without distributed state fails as soon as you have multiple gateway instances. Redis with atomic INCR + EXPIRE is the production standard for shared state.
4
Input validation is necessary but not sufficient for SQL injection. Parameterized queries make injection structurally impossible
use both.
5
Broken Object Level Authorization is the #1 OWASP API risk. Always verify that the authenticated user owns or is permitted to access the requested resource ID.

Common mistakes to avoid

5 patterns
×

Accepting JWT with alg:none due to library default

Symptom
Attackers send crafted tokens with {"alg":"none"} header and empty signature. API accepts them as valid. Any user can impersonate any other user, including admin.
Fix
Explicitly set allowed algorithms in JWT validation: 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)

Symptom
Authenticated user can access /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.
Fix
Add middleware that compares 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

Symptom
Rate limit is 100 req/min per user. With 2 gateway instances, user actually gets 200 req/min (100 per instance). Rate limiter never triggers because each instance stays under its own limit.
Fix
Move rate limit state to Redis with atomic INCR + EXPIRE. Key format: 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

Symptom
Attacker sends 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.
Fix
Rate limit on user ID or API key, not IP. For unauthenticated endpoints, take the first trusted IP from the load balancer (configured to overwrite X-Forwarded-For), not the client-supplied value. Use X-Real-IP set by trusted proxy.
×

String concatenation in SQL queries

Symptom
Input ' OR '1'='1 turns SELECT FROM users WHERE id = ' + userId + ' into SELECT FROM users WHERE id = '' OR '1'='1' — returns all users, bypassing authentication.
Fix
Use parameterized queries (prepared statements) or an ORM. Never concatenate user input into SQL, NoSQL, or command strings. Parameterization separates code from data, making injection structurally impossible.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the difference between authentication and authorization — and gi...
Q02SENIOR
Walk me through how you would secure a REST API that accepts user-genera...
Q03SENIOR
You discover that your API is vulnerable to injection attacks. What imme...
Q04SENIOR
Compare JWT with opaque reference tokens. When would you choose one over...
Q01 of 04SENIOR

Explain the difference between authentication and authorization — and give a real example where an API fails at one but not the other.

ANSWER
Authentication verifies identity — are you who you say you are? Authorization verifies permissions — are you allowed to do this? Example of failing AuthN but having correct AuthZ: A JWT with alg:none bypasses signature verification. The attacker can set any user ID in the payload. The API's authorization logic (checking roles) runs correctly but on attacker-controlled claims — the system authenticates no one but authorizes the attacker as if they were admin. Example of correct AuthN but failing AuthZ: BOLA vulnerability — user Alice authenticates with her own JWT, but the API allows her to change the ID in /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).
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What's the difference between JWT and OAuth 2.0?
02
Should I use HTTP Basic Auth for APIs?
03
How do I test if my API is vulnerable to BOLA (Broken Object Level Authorization)?
04
What is the difference between rate limiting and DDoS protection?
N
Naren Founder & Principal Engineer

20+ years shipping large-scale distributed systems. Notes here come from systems that actually shipped.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's Security. Mark it forged?

8 min read · try the examples if you haven't

Previous
HTTPS and TLS Explained
4 / 10 · Security
Next
CSRF and XSS Prevention