Table of contents
JSON Web Tokens (JWTs) have a reputation for being dangerous, and the reputation is earned. The danger is almost never in the cryptography; it is in the flexibility of the format. A token header announces how it was signed, and a naive verifier believes the announcement. Many published JWT exploits are variations on that single act of misplaced trust.
The jwt
package in nurago
takes a stricter stance: each of the classic attacks is addressed by how the package is built, not by a runtime check that someone could forget to call or misconfigure away. This post goes through the catalogue, one attack at a time.
The whole configuration surface fits in one constructor call:
import "github.com/tecnickcom/nurago/pkg/jwt"
j, err := jwt.New(
signingKey, // shared HMAC secret, length-checked by New
verifyCredentials, // your own username/password check
jwt.WithSigningMethod(jwt.SigningMethodHS256),
jwt.WithExpirationTime(5*time.Minute),
jwt.WithMaxSessionLifetime(8*time.Hour),
jwt.WithPreviousKeys(oldKey), // still accepted during key rotation
)
Attack: alg=none
The JWT specification defines an algorithm literally called none, meaning “unsigned”. A verifier that reads the header, sees alg: none, and concludes no signature is needed will accept any forged claims.
Here there is nothing to trick. The SigningMethod type enumerates exactly three values, the hash-based message authentication code (HMAC) family from RFC 7518 §3.2: HS256, HS384, HS512. New rejects anything else, and there is no algorithm registry to extend. The accepted algorithm is fixed at construction, and a token whose header declares any other value, including none, is rejected with ErrUnexpectedSigningMethod before signature verification is even attempted.
Attack: asymmetric-to-HMAC confusion
A service verifies tokens with an RSA public key. The attacker rewrites the header to alg: HS256 and signs the token with HMAC, using the public key as the HMAC secret. A library that dispatches on the header’s alg will HMAC-verify with a key the attacker also possesses, and the forgery checks out.
The attack requires a verifier willing to switch between an asymmetric and a symmetric algorithm. This package has no asymmetric support at all: no RSA, no Elliptic Curve Digital Signature Algorithm (ECDSA). The same secret key both signs and verifies, and the pinned algorithm must match the header exactly. There is no second algorithm family to confuse the verifier into.
A related trick, repeating the alg parameter so a lax parser keeps the last value, is closed too: the header parser rejects any duplicated JSON Object Signing and Encryption (JOSE) header member, as RFC 7515 §4 permits a verifier to do, rather than silently taking the last occurrence as encoding/json would.
Attack: attacker JSON reaching your parser
Many libraries parse the token, JSON-decode the claims, and only then check the signature. That ordering hands attacker-controlled JSON to your decoder before the token is known to be authentic, exposing every quirk of the decoder to unauthenticated input.
nurago/jwt inverts the order. The signature is verified against the raw signing input first; only then is the payload decoded:
signingInput := tokenString[:len(headerSeg)+1+len(payloadSeg)]
if !c.verifySignature(signingInput, sig) {
return claims, ErrInvalidSignature
}
payload, err := base64.RawURLEncoding.Strict().DecodeString(payloadSeg)
// ... only now is the payload decoded ...
err = json.Unmarshal(payload, claims)
Forged or tampered tokens are rejected before a single byte of their claims payload is interpreted. The one attacker-controlled piece that must be decoded earlier is the JOSE header itself, which is exactly why it gets a strict single-pass parser: a header that is not a JSON object, carries trailing data, repeats a member name, or includes a crit parameter (RFC 7515 §4.1.11 critical extensions, none of which this package implements) is fatal. Other unknown parameters (typ, kid, …) are ignored, since with a pinned symmetric algorithm and mandatory verification they do not alter how a token is processed.
Attack: brute-forceable short keys
HMAC is only as strong as its key, and RFC 7518 §3.2 requires a key at least as long as the hash output. New enforces that floor on every key it will ever use: 32 bytes for HS256, 48 for HS384, 64 for HS512. An undersized key fails construction with ErrWeakKey, which names the offending key, and the same rule covers every previous key registered through WithPreviousKeys, so a key-rotation window does not become a way in for a weak key. Rotation itself is just deployment choreography: new tokens are signed with the current key, old tokens keep verifying against the listed previous keys until they expire, and each signature comparison uses hmac.Equal, which is constant-time.
Attack: oversized tokens
Because the JOSE header has to be base64- and JSON-decoded before the signature can be checked, an unbounded token would let an unauthenticated caller force arbitrarily large decode work. The parser therefore bounds the input before touching any segment: a token larger than the configured cap (8 KiB by default, adjustable with WithMaxTokenBytes) is rejected with ErrTokenTooLarge.
The cap is enforced symmetrically on issuance. The username is not otherwise bounded, so an unusually long one could mint a token past the cap; signing refuses with the same ErrTokenTooLarge instead of issuing a token its own verifier would then reject.
Attack: the immortal session
A renewal endpoint is convenient, and it is also how a stolen token gets kept alive forever: renew shortly before every expiry and the five-minute token becomes a permanent credential.
Every issued token carries an auth_time claim (modelled on the OpenID Connect claim of the same name) recording the original login, preserved verbatim across renewals. WithMaxSessionLifetime then bounds the session at two points. RenewHandler refuses to renew once the session age exceeds the cap, and issuance clamps every minted exp to auth_time plus the cap, so a renewal granted just under the cap does not overshoot it by a further full expiration window. A renewal whose clamped expiry would already be dead at signing is refused with a 401 rather than returned as a token that fails on first use. One documented interaction to keep in mind: if you configure WithClockSkewLeeway, verification extends acceptance by that leeway, so the effective bound is the cap plus the leeway.
The rest of the surface
With the security model settled, the remainder is net/http ergonomics: LoginHandler checks credentials and issues a token, Middleware injects verified claims into the request context for ClaimsFromContext, IsAuthorized and Authenticate validate bearer tokens explicitly, RenewHandler renews near expiry, and IssueToken / VerifyToken work outside HTTP entirely (WebSocket messages, queue payloads, gRPC metadata). Credential verification is delegated to a function you supply, so the package stays agnostic about your password store; pair it with passwordhash
for Argon2id.
What the package leaves to you
These limits are stated in the package documentation and they are the caller’s responsibility:
- Tokens are stateless. There is no server-side revocation before
exp, and renewing a token does not invalidate the previous one, which stays valid until its own expiry. Keep expiration windows short and bound sessions withWithMaxSessionLifetime. - It does not rate-limit failed logins. Brute-force protection (rate limiting, lockout, CAPTCHA) is your job.
- Your
VerifyCredentialsFnmust equalise its own timing between known and unknown users (for example by verifying against a decoy hash), or account existence leaks through response latency even though the default login path returns uniform error messages. - Use HTTPS. A bearer token is a password in transit.
None of these are flaws in the design; they are the parts of the threat model that no signing scheme can solve for you. What the package does take care of is the token machinery itself, from the algorithm set to the parse order: the structural attacks this kind of code so often leaves open (algorithm confusion, parse-before-verify, unbounded input) are each handled deliberately rather than left to the caller.