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:
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. It never prints them.
go run manage.go check --deploywarns (settings:secret_key) when no key is set.apptest.Settings()uses the fixedapptest.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, 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
| 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. |
Verification rules:
Verifyrequires a positivemaxAge. Zero is a programming error, not “no expiry”.errors.Is(err, signing.ErrInvalid)is true for every rejected token, including expired ones. Testsigning.ErrExpiredfirst 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
- 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.
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 theFuzzVerifyfuzz 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.