cicadel

Signed tokens

The signing package issues tokens that cannot be forged or altered. They expire after an age you choose and survive secret-key rotation.

Use them when you need to know that your application created a value, and when:

  • links in emails: confirm an address, accept an invitation, unsubscribe;
  • values you hand to the browser and read back.

Warning

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. It never prints 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, such as a management command or a background job, use app.Signer(purpose). Both use the same keys, so a token issued in a job verifies in a handler.

#API

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.

Verification rules:

  • 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 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. It does not prove the token is unused.

To make a token single-use, include state that the action changes. Compare it on verification. Good choices are a hash of the current email address or of the password hash.

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.

Warning

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.
  • It uses 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.
  • Only the URL-safe base64 alphabet is accepted, so every token has exactly one encoding.
  • The payload is decoded only after the MAC matches.
  • Input longer than a maximum-size token is rejected before any work.

Tests that cover this:

  • 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.
  • internal/scaffold/signing_test.go: a generated project loading keys from the environment.

Type to search this version.