cicadel

Signed tokens

The signing package issues tokens that cannot be forged or altered, expire after an age you choose, and survive secret-key rotation. Use them for links in emails (confirm an address, accept an invitation, unsubscribe), values you hand to the browser and read back, and anything else where you need to know that your application created a value and when.

Tokens are signed, not encrypted: anyone holding a token can decode its payload. Put identifiers in tokens, never passwords or other secrets.

#Ask your agent

Add an email-confirmation link to my Cicadel project using the signing package: a purpose-specific signer, a typed JSON payload and a 48-hour expiry. Configure CICADEL_SECRET_KEY and make check --deploy pass.

#Configure the secret key

Generate a key and supply it through your deployment’s secret configuration:

Terminal
go tool cicadel secretkey       # prints a new 43-character random key
export CICADEL_SECRET_KEY=...   # never commit it
FieldEnvironment variablePurpose
SecretKeyCICADEL_SECRET_KEYSigns new tokens. At least 32 bytes. Empty disables signing.
SecretKeyFallbacksCICADEL_SECRET_KEY_FALLBACKSComma-separated previous keys that still verify tokens. At most 8.

Settings validation rejects short keys, fallbacks without a current key and repeated keys, without printing them. go run manage.go check --deploy warns (settings:secret_key) when no key is set. apptest.Settings() uses the fixed apptest.TestSecretKey, so tests can sign tokens without configuration.

#Sign and verify in a handler

Each kind of token has a purpose, a stable printable name such as "accounts.confirm-email". A token signed for one purpose never verifies for another, even under the same secret key.

Go
type confirmation struct {
	UserID int64  `json:"user_id"`
	Email  string `json:"email"`
}

func sendConfirmation(w http.ResponseWriter, r *http.Request) {
	s, err := signing.For(r.Context(), "accounts.confirm-email")
	if err != nil {
		response.Error(w, 500) // signing.ErrNoKey: CICADEL_SECRET_KEY is missing
		return
	}
	token, err := signing.SignJSON(s, confirmation{UserID: user.ID, Email: user.Email})
	if err != nil {
		response.Error(w, 500)
		return
	}
	link := "https://example.com/confirm?token=" + token // URL-safe; no escaping needed
	// email the link
}

func confirm(w http.ResponseWriter, r *http.Request) {
	s, err := signing.For(r.Context(), "accounts.confirm-email")
	if err != nil {
		response.Error(w, 500)
		return
	}
	c, err := signing.VerifyJSON[confirmation](s, r.URL.Query().Get("token"), 48*time.Hour)
	switch {
	case errors.Is(err, signing.ErrExpired):
		// genuine but too old: offer to send a new link
	case err != nil:
		response.Error(w, 400) // forged, altered, wrong purpose or retired key
		return
	}
	// c.UserID and c.Email are exactly what you signed
}

The application puts its keyring on every request’s context, so signing.For works in any handler. Outside a request (a management command, a background job) use app.Signer(purpose). Both use the same keys, so a token issued in a job verifies in a handler.

APIUse
s.Sign(payload []byte) / s.Verify(token, maxAge)Raw bytes, at most signing.MaxPayload (8 KiB).
signing.SignJSON(s, v) / signing.VerifyJSON[T](s, token, maxAge)Typed payloads. Unknown fields are rejected, so changing T invalidates older tokens.
s.VerifyNoExpiry(token) / signing.VerifyJSONNoExpiry[T]Tokens that deliberately never expire, such as unsubscribe links.
s.Issued(token)When a genuine token was signed.
s.WithClock(now)A copy reading time from now, for tests.

Verify requires a positive maxAge; zero is a programming error, not “no expiry”. errors.Is(err, signing.ErrInvalid) is true for every rejected token, including expired ones; test signing.ErrExpired first when you want to treat expiry differently. A token whose issue time is more than signing.ClockSkew (one minute) in the future is rejected.

#Single-use tokens

A signature proves who issued a token, not that it has not been used. To make a token single-use, include state that the action changes, such as a hash of the current email address or password hash, and compare it on verification. For example, a password-reset token that carries a hash of the stored password hash stops working once the password changes. Never put the raw secret in the token.

#Rotate the secret key

  1. Generate a new key. Deploy it as CICADEL_SECRET_KEY and move the old key to CICADEL_SECRET_KEY_FALLBACKS. New tokens use the new key; existing tokens still verify.
  2. Wait for the longest maxAge your application uses.
  3. Remove the old key from CICADEL_SECRET_KEY_FALLBACKS. Tokens signed with it now fail with signing.ErrInvalid.

If a key leaks, skip the wait: deploy a new key without the leaked one as a fallback. Every token signed with it, including VerifyNoExpiry tokens, becomes invalid immediately.

#How it works

The token format is base64url(payload) "." base36(unix seconds) "." base64url(MAC). The MAC is HMAC-SHA256 over the first two parts, under a per-purpose key derived as HMAC-SHA256(secret key, "cicadel-signing-v1\x00" + purpose). Verification compares MACs in constant time against the current key and each fallback, accepts only the URL-safe base64 alphabet so every token has exactly one encoding, and decodes the payload only after the MAC matches. Input longer than a maximum-size token is rejected before any work.

Evidence: signing/signing_test.go (tampering at every position, purpose and key separation, expiry boundaries, clock skew, rotation, key validation, JSON strictness, concurrency and the FuzzVerify fuzz target), signing_test.go (request-context signing, rotation across deployments and settings validation through a running application) and internal/scaffold/signing_test.go (a generated project loading keys from the environment).

Type to search this version.