Articles / Security

  • jwt
  • authentication
  • go
  • security
  • appsec
  • cryptography

Why Your JWT Library Accepts alg none, and How to Stop It

A JWT tells you how to verify it, and that is the whole problem. The header carries an alg field, and a naive verifier reads it and does what it says. An attacker who sets alg to none gets a token with no signature that some code will accept as valid.

Most maintained libraries refuse this by default now. The bug still turns up, usually because someone wrote a convenient key lookup function, or because a related trick (algorithm confusion) works even when none is blocked.

What a forged token looks like

A JWT is three base64url chunks joined by dots: header, payload, signature. For an unsigned token the signature chunk is simply empty. RFC 7518 section 3.6 defines none as a real algorithm, meant for cases where integrity is guaranteed some other way.

Forging one needs nothing beyond a shell:

b64() { printf '%s' "$1" | base64 | tr '+/' '-_' | tr -d '=\n'; }
h=$(b64 '{"alg":"none","typ":"JWT"}')
p=$(b64 '{"sub":"admin","aud":"my-api","iss":"https://auth.example.com","exp":4102444800}')
echo "$h.$p."

Note the trailing dot: that is the empty signature. If your API returns 200 for that token, anyone can be anyone.

How a verifier gets talked into it

The vulnerable flow is short:

  1. Parse the header and read alg.
  2. Pick the verification routine that matches it.
  3. For none, the routine is "check that the signature is empty", which it is.

The trust decision was made by the party you are trying to authenticate. The algorithm must come from your configuration, never from the token. RFC 8725 (the JWT best current practices) says the same thing in section 3.1 and 3.2.

Where Go code goes wrong

Using github.com/golang-jwt/jwt/v5, the library is safe out of the box: a token with alg: none is rejected unless your key function returns the special value jwt.UnsafeAllowNoneSignatureType. The name is a hint. Code like this undoes the protection:

// Do not do this.
func keyFunc(t *jwt.Token) (any, error) {
	if t.Method.Alg() == "none" {
		// "just for local testing"
		return jwt.UnsafeAllowNoneSignatureType, nil
	}
	return publicKey, nil
}

It looks like a harmless test shortcut. It ships, and then it is a production login bypass. Search your codebase for that constant; it should appear nowhere outside a test file, if at all.

The sibling bug: algorithm confusion

Quick detour, because this one is cleverer. Blocking none does not help if the verifier still trusts the header for everything else. Say your service uses RS256 and the public key is, well, public.

An attacker sets alg to HS256 and signs the token with HMAC, using the public key bytes as the secret. If your key function returns the same PEM bytes regardless of algorithm, an HMAC verifier will use them as the shared secret and the signature checks out. Hang on, why does that work? Because the library asked "which algorithm?" of the attacker, then asked you for "the key", and you handed over the only key you had.

The fix: pin the algorithm

Tell the parser which methods are acceptable, and validate the claims that matter while you are there:

package auth

import (
	"crypto/rsa"
	"errors"
	"fmt"

	"github.com/golang-jwt/jwt/v5"
)

func Verify(raw string, pub *rsa.PublicKey) (*jwt.RegisteredClaims, error) {
	claims := &jwt.RegisteredClaims{}
	tok, err := jwt.ParseWithClaims(raw, claims,
		func(t *jwt.Token) (any, error) { return pub, nil },
		jwt.WithValidMethods([]string{"RS256"}),
		jwt.WithIssuer("https://auth.example.com"),
		jwt.WithAudience("my-api"),
		jwt.WithExpirationRequired(),
	)
	if err != nil {
		return nil, fmt.Errorf("verify token: %w", err)
	}
	if !tok.Valid {
		return nil, errors.New("token not valid")
	}
	return claims, nil
}

Three things are doing the work here:

  • WithValidMethods rejects any token whose alg is not in your list, before the key function matters.
  • The key function returns a typed *rsa.PublicKey, so an HMAC method cannot use it as a secret even if one slipped through.
  • Issuer, audience and expiry are required, so a validly signed token meant for another service does not get in.

Check it with a test, not a hope

Write the attack as a regression test. It is five lines and it fails loudly if someone later loosens the parser:

func TestRejectsAlgNone(t *testing.T) {
	tok := jwt.NewWithClaims(jwt.SigningMethodNone, jwt.RegisteredClaims{
		Subject:   "admin",
		Issuer:    "https://auth.example.com",
		Audience:  jwt.ClaimStrings{"my-api"},
		ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
	})
	raw, err := tok.SignedString(jwt.UnsafeAllowNoneSignatureType)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := Verify(raw, testPublicKey); err == nil {
		t.Fatal("unsigned token was accepted")
	}
}

Do the same for an HS256 token signed with your public key's PEM bytes. If both fail, you have covered the two classic mistakes.

Other libraries, same questions

Whatever language you use, ask the same three things of the library and of your own wrapper code:

  • Does verification require an explicit list of allowed algorithms?
  • Is the key type tied to the algorithm family, or can one blob serve as both an RSA key and an HMAC secret?
  • Does anything in the code path switch behaviour on the header's alg?

If the answer to the last one is yes, read that code carefully. Older versions of several libraries failed these checks, which is why upgrading is worth doing even if your own code looks right.

One last wrinkle: kid and jku headers are also attacker-controlled. Looking up a key by kid from your own fixed key set is fine. Fetching a key from a URL named in the token is the same mistake in a different coat.