AuditCoreAuditCore
criticalCWE-345OWASP A02:2021alg=noneJWT signature bypass

How to fix JWT alg:none

The JWT alg:none vulnerability lets attackers forge tokens with no signature — full authentication bypass. It's been a known issue since 2015 and the libraries are mostly fixed, but it still ships to production every year because of one wrong line in token verification. Here's how to lock it down per library, plus how to verify.

What is JWT alg:none?

JWTs (JSON Web Tokens) are signed credentials. The token has three parts: a header (specifies which signing algorithm), a payload (the claims), and a signature (cryptographic proof of integrity). Verification: parse the header to see what algorithm to use, verify the signature using the corresponding key. The vulnerability is that 'none' is a valid algorithm value per the JWT spec. A token with header { 'alg': 'none' } has no signature — and naive verification accepts it as valid.

Attackers exploit this by taking a legitimate token, decoding it (JWTs are base64-encoded but not encrypted — the payload is readable), changing the user_id or role in the payload, setting alg=none in the header, removing the signature, and re-encoding. If the server's verification doesn't enforce a specific algorithm, it sees alg=none, treats no signature as valid, and authenticates the attacker as whoever they wrote in the payload. Full account takeover in 30 seconds with curl.

The fix is one line per library: explicitly specify which algorithm(s) you accept during verification — never trust the token's header to dictate the algorithm. But the bug keeps shipping because the canonical 'how to verify a JWT' code in tutorials often doesn't show the algorithms parameter, developers copy the minimal version, and the library's default behavior 'just accept whatever the header says' bites them later.

A related class of bug: alg confusion. A token signed with HS256 (symmetric, secret-based) verified by code that expects RS256 (asymmetric, public-key-based) will use the public key as the HMAC secret. Attacker who has the public key (it's PUBLIC) can forge tokens signed with HMAC against it. Same fix: lock the algorithm. AuditCore's JWT analyzer tests for alg=none AND alg-confusion in the same scan.

What an attacker can do

The concrete impact of leaving JWT alg:none unpatched.

Full authentication bypass

Forge any user's token in 30 seconds. Become admin, become any user, access any data. The most direct kind of vulnerability — single bug, full compromise.

Privilege escalation via role / scope claim

JWTs typically encode role/scope in claims ("role":"admin"). Attacker changes own token to claim admin role. If your authz reads role from the JWT (without re-checking against DB), instant escalation.

Long-lived persistent access

JWT typically have long expiry (days/weeks). A forged token works until manual revocation or expiry — and most systems can't revoke individual JWTs (that's the trade-off). Forged token = persistent backdoor.

API key bypass in CI/CD systems

Many CI pipelines authenticate via JWT. Forged token = arbitrary deploys, arbitrary code execution in production runners. CI compromise = supply chain compromise.

Multi-tenant cross-account access

If JWTs encode tenant_id, alg:none lets attacker change to any tenant. SaaS = catastrophic.

OAuth / SSO downstream impact

If your service relies on a partner's JWT (Auth0, Okta, custom IdP) and the partner has alg:none, you inherit the bypass. Audit your IdP and any service that signs JWTs you trust.

How do I know if I'm vulnerable?

Manual: grab a valid JWT from your app (browser cookie, localStorage, Authorization header). Decode it at jwt.io. Modify the payload (change role, user_id, exp). Set the header alg to 'none' or 'None' or 'NONE' (case variations matter — some libraries reject 'none' but accept 'None'). Remove the signature (last segment, after the second dot). Now you have a forged token like header.payload. (note trailing dot, no signature). Send it to a protected endpoint. If you get a successful response with the forged identity, you have alg:none.

Automated: AuditCore's JWT analyzer (Pro/Business tiers) tries 8 alg=none variants (case combinations), 5 algorithm-confusion attacks (HS256 with RSA public key, HS256 with EC public key), and weak-secret brute-force using rockyou.txt + common dev secrets. Free Trial covers basic JWT detection on the homepage; deep analysis needs a JWT to be sent during the crawl, which requires authenticated scanning.

Code review pattern: search your codebase for JWT verification calls. In Node: jwt.verify(token, secret) without an options.algorithms parameter. In Python: jwt.decode(token, secret) without algorithms parameter (PyJWT defaults differ by version). In Java: most jjwt patterns are safe by default, but Jwts.parser().setSigningKey(...) without a signingKeyResolver is suspicious. In Go: jwt.Parse() without explicit Method check. Each is a candidate alg:none site — Step 1 below shows the fixes per library.

How to fix JWT alg:none

6 ordered steps. Apply them in order — each builds on the previous.

  1. 1

    Lock the algorithm in Node.js (jsonwebtoken)

    Pass the algorithms array explicitly to jwt.verify. Without it, the library accepts ANY algorithm including 'none'.

    jsonwebtoken (the most-used Node JWT library) deprecated implicit-algorithm verification but kept backward compatibility. If you don't pass algorithms, you get a deprecation warning in dev — but it still works in prod. Always pass the explicit array. Also reject 'none' even if you somehow allow it via a library bug, by checking the decoded header before verification.

    Node.js / jsonwebtokenStep 1
    import jwt from 'jsonwebtoken';
    
    // VULNERABLE — accepts whatever the header says
    const decoded = jwt.verify(token, secret);
    
    // SECURE — explicit algorithms list
    const decoded = jwt.verify(token, secret, {
      algorithms: ['HS256'],     // exactly the algorithm(s) you use
      issuer: 'your-app',         // belt and suspenders
      audience: 'your-app',
    });
    
    // EXTRA — reject 'none' before verification (defense in depth)
    const header = JSON.parse(
      Buffer.from(token.split('.')[0], 'base64url').toString()
    );
    if (!header.alg || header.alg.toLowerCase() === 'none') {
      throw new Error('Algorithm not allowed');
    }
  2. 2

    Lock the algorithm in Python (PyJWT)

    Always pass algorithms parameter to jwt.decode. PyJWT 2.0+ requires it, but older code often calls without it.

    PyJWT changed its default in 2.0 — older versions would accept any algorithm by default; 2.0+ raises if you don't pass algorithms. If you're on 2.0+ this is mostly fixed. If you're on an older PyJWT (some legacy apps still are), you MUST pass algorithms. Audit every jwt.decode call.

    Python / PyJWTStep 2
    import jwt
    
    # VULNERABLE on PyJWT < 2.0 — algorithm taken from header
    decoded = jwt.decode(token, secret)
    
    # SECURE — explicit algorithms (also required on PyJWT 2.0+)
    decoded = jwt.decode(
        token,
        secret,
        algorithms=["HS256"],  # exactly the algorithm(s) you use
        issuer="your-app",
        audience="your-app",
        options={"require": ["exp", "iat", "iss"]},  # require standard claims
    )
  3. 3

    Lock the algorithm in Java (jjwt / Auth0 java-jwt)

    Use parserBuilder().setSigningKey(...) which forces HS256/RS256 inference, OR explicitly state expected algorithm via JwtParser.

    jjwt 0.11+ is mostly safe — the parserBuilder API infers algorithm from key type. But code paths using SigningKeyResolver (where the resolver returns different keys based on header data) can be tricked into using attacker-chosen algorithms. Pin the algorithm explicitly when using a resolver.

    Java / jjwt 0.11+Step 3
    import io.jsonwebtoken.*;
    
    // SECURE — algorithm inferred from key type, locked
    JwtParser parser = Jwts.parserBuilder()
        .setSigningKey(hmacKey)        // HS256 inferred from key
        .requireIssuer("your-app")
        .requireAudience("your-app")
        .build();
    
    Jws<Claims> jws = parser.parseClaimsJws(token);
    
    // If using a resolver, pin algorithm explicitly:
    SigningKeyResolver resolver = new SigningKeyResolverAdapter() {
      @Override public Key resolveSigningKey(JwsHeader header, Claims claims) {
        // CRITICAL: validate alg, then return key
        if (!"RS256".equals(header.getAlgorithm())) {
          throw new JwtException("Algorithm not allowed");
        }
        return loadPublicKey(header.getKeyId());
      }
    };
  4. 4

    Lock the algorithm in Go (golang-jwt/jwt)

    In jwt.Parse, the keyFunc receives the parsed token — VALIDATE token.Method before returning the key.

    golang-jwt's pattern: Parse() calls keyFunc with the parsed token, expecting it to return the verification key. The classic alg-confusion bug: keyFunc returns the public key without checking token.Method, attacker sends an HS256-signed token, library uses the public key as HMAC secret, signature 'verifies'. Fix: check token.Method type explicitly inside keyFunc.

    Go / golang-jwtStep 4
    import "github.com/golang-jwt/jwt/v5"
    
    // SECURE — explicit method check inside keyFunc
    token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
        // CRITICAL: enforce the expected algorithm family
        if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
        }
        // Reject alg=none as well (some versions return SigningMethodNone here)
        if t.Method.Alg() == "none" {
            return nil, fmt.Errorf("alg none not allowed")
        }
        return secretBytes, nil
    })
  5. 5

    Use the jose library (multi-lang) with explicit algorithms

    If you're picking a JWT library fresh, prefer 'jose' (Node/Browser/Python). Its API requires algorithms upfront — harder to misuse.

    The 'jose' library family (panva/jose for Node, python-jose for Python) is designed around the JWS Validation Profile that requires algorithms upfront. The verifier object encapsulates the algorithm list — there's no way to call it without specifying. If migrating from jsonwebtoken or PyJWT, jose is a safer default for new code.

    Node.js / joseStep 5
    import * as jose from 'jose';
    
    const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
    
    // SECURE by design — algorithms required by the API
    const { payload } = await jose.jwtVerify(token, secret, {
      algorithms: ['HS256'],     // mandatory parameter
      issuer: 'your-app',
      audience: 'your-app',
      requiredClaims: ['sub', 'exp', 'iat'],
    });
  6. 6

    Add unit tests that submit alg=none and alg-confusion tokens

    Every JWT-protected endpoint needs a test sending a forged alg=none token. Assert the response is 401 — never the protected resource.

    Test pattern: take a valid token, modify it to alg=none with no signature, send to a protected endpoint, assert 401. Repeat with HS256 token signed using the public key (alg-confusion test). Run on every PR. Catches regressions when someone refactors auth or upgrades a library and the new defaults differ.

    JestStep 6
    function forgeAlgNone(payload: object): string {
      const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
      const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
      return `${header}.${body}.`;  // empty signature
    }
    
    describe('JWT alg:none rejection', () => {
      it('rejects alg=none forged token on /api/me', async () => {
        const forged = forgeAlgNone({ sub: 'admin', role: 'admin', exp: Date.now()/1000+3600 });
        const res = await api.get('/api/me')
          .set('Authorization', `Bearer ${forged}`);
        expect(res.status).toBe(401);
        expect(res.body.email).toBeUndefined();
      });
    
      // Test case variations (None, NONE, nOne) — some libraries differ
      it.each(['none','None','NONE','nOne'])('rejects alg=%s', async (alg) => {
        const header = Buffer.from(JSON.stringify({ alg, typ: 'JWT' })).toString('base64url');
        const body = Buffer.from(JSON.stringify({ sub: 'admin' })).toString('base64url');
        const res = await api.get('/api/me')
          .set('Authorization', `Bearer ${header}.${body}.`);
        expect(res.status).toBe(401);
      });
    });

How to verify the fix

Manual: forge an alg=none token for an admin/privileged user. Send to your most-protected endpoint. You should get 401, not the resource. Repeat for case variations (None, NONE, nOne, NoNe) — case-handling bugs exist in some libraries. Repeat with empty signature, with valid HMAC signature using the public RSA key (alg confusion).

Automated: AuditCore's JWT analyzer is built for this. It runs on Pro and Business tiers — captures a valid JWT during authenticated crawl, then attempts 8 alg=none variants and 5 alg-confusion attacks. Findings include the forged token and the response code. Free Trial detects JWT presence but doesn't run the deep analysis (needs a captured token).

Long-term: keep step 6 unit tests in CI. Set up a weekly scheduled scan via AuditCore on staging — diff alerts catch regressions when someone upgrades a JWT library or refactors auth. Pair with monitoring: any alg=none in your auth logs is an active exploitation attempt and should page on-call.

FAQ

Frequently asked questions

Why did the JWT spec include alg=none in the first place?+

The original use case: JWTs that don't need a signature because they're already inside an authenticated context (e.g. inside a TLS-protected session, or wrapped by an outer signed envelope). It made sense in 2014. In practice it became an exploit vector almost immediately. Modern libraries either reject alg=none by default or require you to opt in explicitly.

Is HS256 with a strong secret safe?+

Yes — IF the secret is actually strong. The bigger risk for HS256 is weak secrets. AuditCore's JWT analyzer runs rockyou.txt + common dev secrets ('secret', 'jwt-secret', 'changeme', etc.) against captured tokens. Many production JWT secrets are guessable in seconds. Use random 256-bit secrets generated by openssl rand -hex 32 or equivalent. Never reuse a JWT secret across environments.

What's algorithm confusion?+

A token signed with HS256 (symmetric, uses 'secret' for both signing and verification) submitted to a verifier expecting RS256 (asymmetric, uses RSA public key for verification). If the verifier doesn't lock the algorithm, it'll use the public key (which is PUBLIC) as the HMAC secret. Attacker has the public key — they can forge HS256 tokens against it. Same fix as alg=none: lock the algorithm.

Should I migrate from JWTs to opaque tokens?+

Sometimes. Opaque tokens (random strings stored server-side, looked up on each request) are simpler and safer — no signature to forge, instant revocation. The trade-off: every request hits the database/cache. JWTs are stateless (great for horizontal scale) but have the signature trap. For B2C with high traffic and short-lived sessions, opaque tokens or Redis-backed sessions are often better. For B2B SaaS or microservices, JWTs are still the right choice.

Are JWE (encrypted JWTs) safe from alg:none?+

JWE has its own equivalent — alg='dir' with no encryption, or alg='none' for the JWS layer wrapping JWE. Same family of bugs. Always lock both the JWE algorithm AND the JWS algorithm if you're using nested tokens. The 'jose' library handles this safely; rolling your own is risky.

Should the JWT secret ever be in source code?+

Never. Use environment variables, a secrets manager (AWS Secrets Manager, Vault, GCP Secret Manager), or a managed identity. Rotate quarterly. JWT secret in git history is a different vulnerability class but commonly co-occurs — gitleaks finds them in old commits even after they're 'removed'. AuditCore runs gitleaks if you connect a repo.

How do I revoke a JWT before it expires?+

JWTs are stateless by design — you can't 'revoke' one without server-side state. Workarounds: (1) short expiry (5-15 min) + refresh tokens — limits blast radius. (2) Maintain a revocation list (jti claim + Redis check) — adds DB hit per request, defeats some of the JWT advantage. (3) On critical events (password change, role change), rotate the signing secret — invalidates ALL outstanding tokens. Most production systems use (1) + occasional use of (3).

What about the JWT 'kid' (Key ID) attack?+

If your JWT verifier uses the 'kid' header to look up which key to use, attackers can manipulate it. Attacks: kid='/dev/null' → empty key reads as 'no signature'. kid SQL injection if you use it as a database key. kid path traversal if you use it as a filename. Sanitize kid values strictly: allow-list of known kid values, never use untrusted kid as filesystem path or SQL value.

Don't just guess — scan and verify

AuditCore Free Trial scans your homepage for JWT alg:none and 50+ other vulnerability classes. No credit card. Results in 60 seconds.