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:
go tool cicadel secretkey # prints a new 43-character random key
export CICADEL_SECRET_KEY=... # never commit it| Field | Environment variable | Purpose |
|---|---|---|
SecretKey | CICADEL_SECRET_KEY | Signs new tokens. At least 32 bytes. Empty disables signing. |
SecretKeyFallbacks | CICADEL_SECRET_KEY_FALLBACKS | Comma-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.
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.
| API | Use |
|---|---|
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
- Generate a new key. Deploy it as
CICADEL_SECRET_KEYand move the old key toCICADEL_SECRET_KEY_FALLBACKS. New tokens use the new key; existing tokens still verify. - Wait for the longest
maxAgeyour application uses. - Remove the old key from
CICADEL_SECRET_KEY_FALLBACKS. Tokens signed with it now fail withsigning.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).