Skip to content
gohacks.dev
Web

JWT in Go Done Carefully and the Cases to Avoid It

How JWT verification fails in Go and how to prevent it: pinning the algorithm, the none attack, expiry, audience and issuer checks, and when to skip JWT.

9 min read
Summarize in AI
Flat illustration of a shield with a lock, a key and a password field, with the JSON Web Tokens logo.

JSON Web Tokens are everywhere, usually chosen because they sound modern, and sometimes chosen correctly. A JWT is three base64url parts, a header, a claims payload and a signature, joined by dots. Anyone who holds the right key can check that the claims were issued by someone who held the signing key and have not been altered since. That is a useful primitive, and the format has also produced a long list of real vulnerabilities, almost all of them in the verifier: code that checks the token carelessly. This article shows the careless versions failing, the strict version passing, and an honest list of when to choose something else.

Tested with Go 1.25.6; a hand-written HS256 verifier built from the standard library, and the golang-jwt library read from its documentation and not run here

It continues the series on authentication middleware and sessions, and relates to OAuth 2 login which issues tokens of this kind. It belongs to the web cluster that ends in a production HTTP server. Everything here was run with the standard library on Go 1.25.6, using a deliberately minimal HS256 implementation to show the failure modes. For production, use a maintained library (see the end), which I describe from its documentation and did not run.

Anatomy, and what is signed

A token looks like header.payload.signature. The header names the algorithm (alg) and key (kid). The payload holds the claims: sub (the subject), iss (issuer), aud (audience), exp (expiry), nbf (not before), iat (issued at), plus whatever the application adds. The signature covers the first two parts. The payload is only encoded, not encrypted: anyone can read it, so no secrets go in it. A signed token proves integrity and origin; it does not provide confidentiality.

The verifier that trusts the token

The first mistake is the one that has broken real systems. The header says which algorithm to use to verify the signature, and a verifier that believes it can be told to use none:

jwt.goGo
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"errors"
	"strings"
	"time"
)

var b64 = base64.RawURLEncoding

type header struct {
	Alg string `json:"alg"`
	Typ string `json:"typ"`
}

type claims struct {
	Sub string `json:"sub"`
	Iss string `json:"iss"`
	Aud string `json:"aud"`
	Exp int64  `json:"exp"`
	Nbf int64  `json:"nbf"`
}

func sign(key []byte, signingInput string) string {
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(signingInput))
	return b64.EncodeToString(mac.Sum(nil))
}

// issue creates an HS256 token.
func issue(key []byte, c claims) string {
	h, _ := json.Marshal(header{Alg: "HS256", Typ: "JWT"})
	p, _ := json.Marshal(c)
	in := b64.EncodeToString(h) + "." + b64.EncodeToString(p)
	return in + "." + sign(key, in)
}

// parts splits and decodes a token without verifying anything.
func parts(token string) (h header, c claims, signingInput, sig string, err error) {
	segs := strings.Split(token, ".")
	if len(segs) != 3 {
		return h, c, "", "", errors.New("not a JWT")
	}
	hb, err1 := b64.DecodeString(segs[0])
	pb, err2 := b64.DecodeString(segs[1])
	if err1 != nil || err2 != nil || json.Unmarshal(hb, &h) != nil || json.Unmarshal(pb, &c) != nil {
		return h, c, "", "", errors.New("malformed token")
	}
	return h, c, segs[0] + "." + segs[1], segs[2], nil
}

// verifyNaive is the dangerous version: it lets the token choose how it is verified.
func verifyNaive(key []byte, token string) (claims, error) {
	h, c, in, sig, err := parts(token)
	if err != nil {
		return c, err
	}
	switch h.Alg {
	case "none":
		return c, nil // "no signature needed": whoever wrote the token decided that
	case "HS256":
		if !hmac.Equal([]byte(sign(key, in)), []byte(sig)) {
			return c, errors.New("bad signature")
		}
		return c, nil
	}
	return c, errors.New("unsupported algorithm")
}

// verifyStrict pins the algorithm and checks the claims, in that order.
func verifyStrict(key []byte, token, wantIssuer, wantAudience string, now time.Time) (claims, error) {
	h, c, in, sig, err := parts(token)
	if err != nil {
		return c, err
	}
	if h.Alg != "HS256" { // decided by the verifier, never by the token
		return c, errors.New("unexpected algorithm " + h.Alg)
	}
	if !hmac.Equal([]byte(sign(key, in)), []byte(sig)) { // constant-time comparison
		return c, errors.New("bad signature")
	}
	const leeway = 30 * time.Second // tolerate small clock differences between servers
	switch {
	case c.Exp == 0 || now.After(time.Unix(c.Exp, 0).Add(leeway)):
		return c, errors.New("token expired")
	case c.Nbf != 0 && now.Add(leeway).Before(time.Unix(c.Nbf, 0)):
		return c, errors.New("token not valid yet")
	case c.Iss != wantIssuer:
		return c, errors.New("wrong issuer")
	case c.Aud != wantAudience:
		return c, errors.New("token is for another audience")
	}
	return c, nil
}

The naive verifier is a faithful miniature of a real class of bugs. In the format's specification, alg: none is a legitimate value for tokens secured some other way, and early libraries honored it. An attacker needs no key at all: write a header with none, any claims, an empty signature, and the verifier accepts it.

Forging and rejecting

The demo issues a real token, then builds a forged one claiming to be an admin with no signature, and runs both through both verifiers. It also tampers with a genuine token's payload, expires another, and presents one meant for a different service:

main.goGo
package main

import (
	"fmt"
	"strings"
	"time"
)

func main() {
	key := []byte("a-32-byte-secret-from-configuration!")
	now := time.Now()
	good := issue(key, claims{Sub: "ada", Iss: "auth.example.com", Aud: "orders-api", Exp: now.Add(10 * time.Minute).Unix()})

	// An attacker forges a token: algorithm none, any claims, no signature.
	forgedHeader := b64.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
	forgedClaims := b64.EncodeToString([]byte(`{"sub":"admin","iss":"auth.example.com","aud":"orders-api","exp":4102444800}`))
	forged := forgedHeader + "." + forgedClaims + "."

	// Tamper with a real token: change the subject but keep the old signature.
	segs := strings.Split(good, ".")
	segs[1] = b64.EncodeToString([]byte(`{"sub":"admin","iss":"auth.example.com","aud":"orders-api","exp":4102444800}`))
	tampered := strings.Join(segs, ".")

	expired := issue(key, claims{Sub: "ada", Iss: "auth.example.com", Aud: "orders-api", Exp: now.Add(-time.Hour).Unix()})
	otherService := issue(key, claims{Sub: "ada", Iss: "auth.example.com", Aud: "billing-api", Exp: now.Add(time.Hour).Unix()})

	check := func(label, token string) {
		c1, err1 := verifyNaive(key, token)
		_, err2 := verifyStrict(key, token, "auth.example.com", "orders-api", now)
		fmt.Printf("%-28s naive: %-22s strict: %v\n", label, verdict(c1.Sub, err1), verdict("ok", err2))
	}
	check("genuine token", good)
	check("forged, alg none", forged)
	check("tampered payload", tampered)
	check("expired token", expired)
	check("token for another service", otherService)
}

func verdict(who string, err error) string {
	if err != nil {
		return "rejected (" + err.Error() + ")"
	}
	return "accepted as " + who
}
Text
genuine token                naive: accepted as ada        strict: accepted as ok
forged, alg none             naive: accepted as admin      strict: rejected (unexpected algorithm none)
tampered payload             naive: rejected (bad signature) strict: rejected (bad signature)
expired token                naive: accepted as ada        strict: rejected (token expired)
token for another service    naive: accepted as ada        strict: rejected (token is for another audience)

Read the two columns. The genuine token passes both. The forged none token is accepted by the naive verifier as admin, which is the vulnerability, and rejected by the strict one for its algorithm. The tampered payload is caught by both, because the signature no longer matches. The expired token and the token meant for a different service are accepted by the naive verifier, which checks only the signature, and rejected by the strict one. That last row is the subtle one: a token validly signed by your issuer for billing-api is not a token for orders-api, and accepting it lets a user of one service act on another. The audience claim is the defense, and it is the check most often forgotten.

The other ways it goes wrong

Algorithm confusion. If a service accepts both HS256 and RS256, an attacker takes the service's public key (public by design), treats it as an HMAC secret, signs a token with HS256 using those bytes, and a verifier that selects the algorithm from the header happily verifies the HMAC with the public key. The fix is the same as above: the verifier's code, not the token, decides the algorithm, and a key is configured for one algorithm. Good libraries make you pass the expected method.

Shared secrets that spread. HS256 means every verifier can also mint tokens. For one service that signs and verifies its own tokens, that is fine. For tokens verified by many services, use an asymmetric algorithm (RS256, ES256, EdDSA): the issuer holds the private key and publishes the public one, usually as a JWKS document, and verifiers can check but never forge. Support kid so keys can rotate: select the verification key by kid from a set you control, and never fetch a key from a URL named in the token.

Secrets that are too short. An HS256 secret needs at least 256 bits of randomness. A human-chosen password can be brute-forced offline from any captured token, since the token is the verification oracle.

Sensitive data in the payload. Claims are readable by anyone who holds the token, including the user, logs and browser extensions. Put identifiers in, not secrets.

No way to revoke. A signed token is valid until exp, wherever it is used. Logging out, deleting a user or changing a role does not invalidate tokens already issued. You can add a denylist (which brings state back), keep lifetimes to minutes and issue new ones from a refresh token that can be revoked, or avoid the problem with a server-side session. Short access tokens plus revocable refresh tokens is the standard OAuth design, and it is more machinery than it sounds like.

Storage in the browser. A token in localStorage is readable by any script on the page, so a single XSS steals it. A cookie with HttpOnly, Secure and SameSite is safer for browser clients (cookies), at which point the token is a heavier version of a session ID.

When not to use a JWT

For a browser login to your own site, a server-side session is simpler, revocable immediately and keeps nothing sensitive in the browser except an opaque ID (sessions). For a single service verifying its own users, you gain nothing from signed claims over a database or cache lookup. JWTs earn their place where a token must be verified by a different party than the one that issued it without a network call: service-to-service calls inside a platform, delegated access to APIs (OAuth 2 access tokens), and federation. Even there, an opaque token with an introspection endpoint is an honest alternative when you need instant revocation.

What to use in production

Do not ship the verifier above; it exists to show failures. Use a maintained library, and configure it strictly. In Go the usual choice is github.com/golang-jwt/jwt/v5 (documentation), whose parser lets you set the allowed signing methods (jwt.WithValidMethods), the expected issuer and audience (WithIssuer, WithAudience) and leeway for clock skew, and validates exp and nbf by default. I read those options from its documentation and did not run the library for this article, so check the current version's API before relying on the names. For tokens from an identity provider, prefer the OpenID Connect support in github.com/coreos/go-oidc or golang.org/x/oauth2, which also handle key discovery. The standard itself is RFC 7519, and RFC 8725, the JWT best current practices, is a short and excellent list of the pitfalls above. The site's JWT decoder is handy for looking at a token's claims, which also illustrates that the payload is not secret.

Taggedhardening
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.