jwt

package module
v0.0.3 Latest Latest
Warning

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

Go to latest
Published: Jul 14, 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

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

Signing and verifying are done and tested (native + WASM).

Not yet usable from a frontend that has no secret, and not yet proven to interoperate with other JWT implementations. Both, plus clock-skew tolerance and key rotation, are specified in docs/PLAN.md.

Testing

gotest          # both suites: native + wasm
gotest -tinygo  # compiles the WASM suite with TinyGo

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.

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 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.

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 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