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

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

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

| 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

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