jwt

package module
v0.1.13 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 21, 2026 License: MIT Imports: 6 Imported by: 0

README

tinywasm/jwt

Isomorphic JWT (HS256) for the tinywasm ecosystem: the same code signs and verifies on the native backend and inside a WASM/edge binary (browser, Cloudflare Workers, goflare).

It exists so a consumer that only needs to verify a token does not have to import an entire auth stack (ORM, bcrypt, OAuth, a database driver) to do it.

import "github.com/tinywasm/jwt"

secret := []byte("a-256-bit-secret")

token, err := jwt.Sign(secret, jwt.NewClaims(userID, 3600)) // ttl in seconds
if err != nil {
    return err
}

claims, outcome, err := jwt.Verify(secret, token)
if err != nil {
    return err // YOU are broken (empty secret) — a config bug, not a bad token
}
switch outcome {
case jwt.Valid:
    use(claims.Sub)
case jwt.Expired:
    // not an attack: the session simply ended, ask for a new login
case jwt.Forged:
    // unauthentic — raise the alarm
}

The two return channels mean different things, and that separation is the API:

Channel Means Example
error the caller is broken empty secret — a configuration bug
Outcome what the token is Valid, Expired, Forged
Frontend / Unverified Decode

If you are on the frontend (browser) or an edge worker without access to the secret, you can still read the claims to show the user's name or know when the session expires:

claims, err := jwt.DecodeUnverified(token)
if err == nil {
    fmt.Println("Expires at:", claims.Exp)
}

Warning: DecodeUnverified does NOT check the signature. Treat the result as a display hint, never as an authorization decision.

The split is:

  • Frontend/edge without the secretDecodeUnverified, UI only (when do I expire?).
  • Backend/edge with the secretVerify, always, for any decision.
Clock skew

Verify tolerates jwt.Leeway (60s) of clock drift on exp only: an edge worker's clock and the backend's are never quite the same, and without tolerance a freshly minted token can produce intermittent 401s. It is a constant, not a parameter: the zero value of an optional knob would reintroduce exactly those 401s.

Key rotation

Changing the secret must not log every user out. VerifyAny accepts a list — new secret first, old one second — and emits with the new one while sessions signed with the old one stay valid:

claims, outcome, err := jwt.VerifyAny([][]byte{newSecret, oldSecret}, token)

The empty-secret refusal does not relax for coming in a list, every secret is tried before answering (no timing short-circuit on the first match), and an expired token authentic under any of the secrets is still Expired, never Forged.

Authorization header
token, ok := jwt.FromBearer(r.Header.Get("Authorization"))

Case-insensitive on the scheme (bearer is legal per RFC 6750); a missing or non-Bearer header yields ok == false — the token is never guessed.

An expired token is not an error: it is Verify working correctly. Keeping expiry out of the error channel is what stops a caller writing if err != nil { alarm() } and reporting every routine session expiry as a forgery — which is exactly the bug this library was extracted to fix.

Forged is the zero value: an unset verdict denies.

Design

HS256 only. No algorithm negotiation. That is the security model, not a limitation.

Verify never reads the alg field of the token — it always recomputes HS256. Choosing the algorithm from a value carried inside the untrusted token is the classic alg-confusion vulnerability, and it is how {"alg":"none"} forgeries get accepted.

Claims is a closed struct (Sub, Exp, Iat), never a map[string]any bag.

The library refuses rather than returning something that merely looks fine:

Refused Why
empty secret HMAC over an empty key is valid math — it mints tokens anyone can forge
empty subject a token that authenticates nobody would let "" through as an identity
token without exp it is malformed, not eternal
any signature mismatch compared in constant time (crypto.HMACEqual)

Forged does not say why: distinguishing "bad signature" from "bad base64" tells an attacker where they stand. And no outcome other than Valid returns usable claims — an expired or forged token authorizes nobody, so handing its subject back would only invite a caller to use it.

Status

Complete for its scope: sign, verify, unverified decode, clock-skew leeway, key rotation and Bearer extraction — tested on native, WASM and TinyGo (the full suite is green under gotest -tinygo, which compiles the WASM tests with the TinyGo toolchain; a plain gotest cannot prove that).

Interoperability is proven with known-answer vectors (RFC 7515): a token minted by an external implementation verifies, and Sign over fixed claims reproduces the expected string byte for byte. Verify is additionally fuzzed (tests/fuzz_test.go).

A security review of the whole surface lives in docs/SECURITY_AUDIT.md.

Testing

gotest          # both suites: native + wasm
gotest -tinygo  # compiles the WASM suite with TinyGo
go test -run='^$' -fuzz=FuzzVerify -fuzztime=60s ./tests  # fuzz the verifier

See AGENTS.md for the constraints any change must respect.

Documentation

Overview

Package jwt signs and verifies JSON Web Tokens (HS256) isomorphically: the same code runs on the native backend and inside a WASM/edge binary.

The library is deliberately small and closed: HS256 only, one claim set, no algorithm negotiation. See docs/ARCHITECTURE.md for why.

Index

Constants

View Source
const DefaultTTL = 86400 // 24h, in seconds

DefaultTTL is the lifetime NewClaims uses when ttl <= 0.

View Source
const Leeway = 60

Leeway is the clock skew tolerated when checking exp. It is a constant rather than a parameter because the zero value (no leeway) would cause intermittent 401s in distributed systems due to clock drift.

Variables

View Source
var (
	// ErrEmptySecret is a refusal, not a failure. HMAC over an empty key is valid math:
	// it produces a token that verifies. A zero-value config would therefore mint
	// tokens that ANYONE can forge, and nothing would ever look wrong.
	//
	// It is an `error`, not an Outcome, because it means THE CALLER is broken — not the
	// token. The two must never share a channel.
	ErrEmptySecret = fmt.Err("jwt", "secret", "empty")

	// ErrEmptySubject: a token that authenticates nobody is never what the caller meant.
	ErrEmptySubject = fmt.Err("jwt", "subject", "empty")
)

Functions

func FromBearer added in v0.1.0

func FromBearer(authorizationHeader string) (token string, ok bool)

FromBearer extracts the token from an Authorization header value. A missing or non-Bearer header yields ok == false; the token is never guessed. Case-insensitive for the "Bearer " scheme.

func Sign added in v0.0.2

func Sign(secret []byte, c Claims) (string, error)

Sign returns a signed HS256 token. It refuses to mint a forgeable or meaningless token rather than handing back one that merely looks fine.

func Verify added in v0.0.2

func Verify(secret []byte, token string) (Claims, Outcome, error)

Verify authenticates a token and returns its verdict.

The two return channels mean different things, and that separation IS the API:

error   — THE CALLER is broken (an empty secret). A configuration bug.
Outcome — what the TOKEN is: Valid, Expired, or Forged. Never an error.

Claims are meaningful only when the Outcome is Valid; otherwise they are zero.

The `alg` field of the header is READ BY NOBODY, and that is the point: this verifier always recomputes HS256. Choosing the algorithm from a value carried inside the untrusted token is the classic alg-confusion vulnerability — it is how `{"alg":"none"}` forgeries get accepted. Do not "fix" this by parsing the header.

func VerifyAny added in v0.1.0

func VerifyAny(secrets [][]byte, token string) (Claims, Outcome, error)

VerifyAny tries each secret and accepts the token if any of them authenticates it. For rotation: pass the new secret first, the old one second.

The empty-secret rule does not relax for coming in a list: any empty entry is refused before the token is even looked at, exactly like Verify refuses an empty secret regardless of the token's shape.

Every secret is tried before answering — no early exit on the first match, and the payload is decoded only after the full traversal — so the timing of the verdict does not tell a caller (or an attacker measuring it) WHICH secret matched.

Types

type Claims added in v0.0.2

type Claims struct {
	Sub string // subject: who the token authenticates
	Exp int64  // expiry, unix seconds
	Iat int64  // issued at, unix seconds
}

Claims is the payload. Closed on purpose: the registered claims this ecosystem actually uses. No `map[string]any` bag — that is how JWT libraries grow holes.

func DecodeUnverified added in v0.1.0

func DecodeUnverified(token string) (Claims, error)

DecodeUnverified reads the claims WITHOUT checking the signature. The token is UNTRUSTED input: treat the result as a display hint, never as an authorization decision.

It follows the same shape requirements as Verify (3 parts, base64 valid, sub and exp present).

func NewClaims added in v0.0.2

func NewClaims(subject string, ttl int) Claims

NewClaims builds a claim set valid for ttl seconds from now.

func (*Claims) DecodeFields added in v0.0.2

func (c *Claims) DecodeFields(r model.FieldReader)

func (Claims) EncodeFields added in v0.0.2

func (c Claims) EncodeFields(w model.FieldWriter)

func (Claims) IsNil added in v0.0.2

func (c Claims) IsNil() bool

type Outcome added in v0.0.3

type Outcome uint8

Outcome is the CLOSED set of verdicts on a token. It is not an error: a token being expired or forged is this function working correctly, and the caller must act differently on each — "log in again" is not "you are under attack".

It is an enum rather than a sentinel error on purpose. With `(Claims, error)` a caller can write `if err != nil { alarm() }` and collapse a routine expiry into a forgery alarm — which is exactly what happened in tinywasm/user, drowning real tampering in noise. A closed type makes that collapse something you have to deliberately write, not something you get by forgetting.

const (
	// Forged is the ZERO VALUE: closed by default. Anything not proven authentic —
	// wrong shape, bad signature, undecodable payload, missing claims — is this.
	// The verdict does not say WHICH: telling "bad signature" apart from "bad base64"
	// tells an attacker where they stand.
	Forged Outcome = iota

	// Valid: authentic and in date. The Claims returned alongside are trustworthy.
	Valid

	// Expired: authentic, but past its `exp`. NOT an attack — the session simply ended.
	Expired
)

func (Outcome) String added in v0.0.3

func (o Outcome) String() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL