Signing keys
Wicket signs JWTs (access tokens and ID tokens) with asymmetric keys and publishes the public keys at /.well-known/openid-configuration/jwks so resources can validate signatures. This page describes the two ways to provide keys, and the correct way to turn on automatic rotation in v0.1.4.
No keys, no tokens
Wicket does not generate keys automatically. If the host neither injects signing credentials nor wires up automatic management as described on this page, the token endpoint returns HTTP 501 with the error message no signing credential available for the requested algorithms, and JWKS is {"keys":[]}.
Choosing between the two approaches
| Approach | Suited for | Rotation |
|---|---|---|
| Inject signing credentials manually | Development; or teams that already have a key management system (KMS, key files + a release process) | Scheduled by the host; you handle the overlap of old and new keys yourself |
Automatic management with keymgmt | You want Wicket to generate, pre-publish, and rotate keys and retain old public keys | Automatic; requires a persistent record store and an encrypting protector |
Providing signing credentials manually
The two injection points split the work as follows:
cfg.SetSigningCredentialStores(...): the private key credentials used for signing;cfg.SetValidationKeysStores(...): the public keys used for signature validation, which is the source of the JWKS output.
The two must match: the kid used for signing must appear in JWKS, or resources can't validate signatures. The following example loads an RSA private key from a PEM file (PKCS#8):
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"os"
wicketmodels "github.com/gonewx/wicket/models"
"github.com/gonewx/wicket/storage"
"github.com/gonewx/wicket/storage/memory"
"github.com/gonewx/wicket/tokens"
"github.com/lestrrat-go/jwx/v3/jwk"
)
func loadSigningKey(path string) ([]storage.SigningCredentialStore, []storage.ValidationKeysStore, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, nil, err
}
block, _ := pem.Decode(data)
if block == nil {
return nil, nil, errors.New("no PEM block found")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, nil, err
}
priv, ok := parsed.(*rsa.PrivateKey)
if !ok {
return nil, nil, errors.New("not an RSA private key")
}
// kid is the RFC 7638 JWK thumbprint: the same public key always yields the same kid
key, err := jwk.Import(priv)
if err != nil {
return nil, nil, err
}
if err := jwk.AssignKeyID(key); err != nil {
return nil, nil, err
}
kid, _ := key.KeyID()
cred, err := tokens.NewSigningCredentials(priv, tokens.WithAlgorithm("RS256"), tokens.WithKid(kid))
if err != nil {
return nil, nil, err
}
info := &wicketmodels.SecurityKeyInfo{Key: priv.Public(), SigningAlgorithm: cred.Algorithm}
return []storage.SigningCredentialStore{memory.NewMemorySigningCredentialStore(cred)},
[]storage.ValidationKeysStore{memory.NewMemoryValidationKeysStore([]*wicketmodels.SecurityKeyInfo{info})}, nil
}signing, validation, err := loadSigningKey("/run/secrets/wicket-signing.pem")
if err != nil {
log.Fatal(err)
}
cfg.SetSigningCredentialStores(signing)
cfg.SetValidationKeysStores(validation)Key points:
- Use the RFC 7638 thumbprint as the kid; it is determined solely by the public key. When multiple replicas load the same private key, their kids naturally agree with no extra coordination.
- In development you can call
rsa.GenerateKeyin-process as in Quick start, but the key changes on every restart, all previously issued tokens become unverifiable, and you can't run multiple replicas. memory.NewMemorySigningCredentialStore/NewMemoryValidationKeysStoreonly "wrap an already loaded key in the interface"; they have nothing to do with "data persistence". Persisting and distributing the key itself is the job of your key file or KMS.- Manual rotation: first add the new public key to the validation store (JWKS then publishes both old and new keys), wait for the resources' JWKS caches to refresh, then switch the signing credential. Keep the old public key until at least "longest token lifetime + clock skew" has passed before removing it. Both injection points also have a
...Provider(func() ...)form, which can return the latest key set at runtime.
JWKS publishing
- Path:
/.well-known/openid-configuration/jwks; the discovery document'sjwks_uripoints to it. - Contents: the public keys returned by all validation stores, merged and deduplicated (only one entry per kid). A read error from a single store is isolated; publishing fails only when every store errors and no key at all is available.
- When
Discovery.ShowKeySet = falseorEndpoints.EnableDiscoveryEndpoint = false, the JWKS endpoint returns 404.
Automatic key management
The keymgmt package provides a key lifecycle manager, keymgmt.Manager. It stores each key's state as a keymgmt.Record and moves it through four stages:
candidate ──warm-up over──▶ active ──signing window ends──▶ retained ──retention over──▶ retired
(published to JWKS, (signing, (no longer signing, still in (no longer in
not signing yet) in JWKS) JWKS to validate issued tokens) JWKS)- When there is no usable key at first, an active key is created directly (skipping candidate).
- When less than
ActivationLeadTimeremains in the current active key's signing window, a candidate is created early and its public key is published immediately; after a warm-up ofmax(ActivationLeadTime, CacheTTL)it becomes active, and the old key moves to retained. - All transitions happen as a side effect of signing or reading JWKS (with caching); the manager starts no background goroutines.
- When multiple replicas share the same record store, state updates carry a version number for optimistic concurrency control; replicas eventually converge and don't create duplicate keys.
This behavior was verified on v0.1.4 with an injected clock: with a 24-hour rotation interval, a candidate was generated about 5 minutes before expiry; after expiry the new key took over signing and the old key moved to retained; once the retention period ended, the old key left JWKS.
Settings and defaults
The fields of keymgmt.Config map one-to-one to WicketOptions.KeyManagement (KeyManagementOptions). Zero durations are filled with defaults by the manager:
| Field | Default | Notes |
|---|---|---|
Enabled | false | |
AutoCreate | *bool in the options; treated as true when unset | When false, the manager returns an error if no key is available instead of creating one |
RotationInterval | 90 days | A key's signing lifetime |
ActivationLeadTime | Equal to CacheTTL (5 minutes) | Candidate warm-up time; must be less than RotationInterval |
RetentionDuration | 14 days | How long the public key keeps being published after signing stops; should cover the longest token lifetime + clock skew |
CacheTTL | 5 minutes | Key snapshot cache duration |
ClockSkew | 5 minutes | Clock skew margin for the publishing window |
AllowedAlgorithms | ["RS256"] | Options: RS256/384/512, ES256/384/512 |
DefaultAlgorithm | "RS256" | Algorithm for newly created keys; must be in the allowed set |
AllowMultipleActive | false |
The options struct also has three injection points: Protector (the private key protector), Clock, and Entropy. The record store is injected through cfg.SetRecordStore.
v0.1.4: the host must build the manager
Changing opts.KeyManagement has no effect
The keymgmt.Manager in the container is constructed during di.NewRoot() and copies the options as they were at that moment (Enabled = false). Setting opts.KeyManagement.Enabled = true afterward doesn't change it (in testing, root.Ptr_Manager().Enabled() is still false), and the token endpoint still reports "no signing credential".
The approach that works: the host constructs an enabled manager itself, then adapts it into a signing credential store and a validation key store and injects those. The code below has been compiled and verified end to end on v0.1.4 (client_credentials issuance succeeded, and JWKS published that kid).
Step 1: prepare a protector that actually encrypts. The manager requires the protector to "change and then restore" data. Wicket's built-in default implementation passes this check but is not suitable for production and provides no confidentiality for the private keys in the record store. In production, use an AES-GCM implementation like the one below, with the master key coming from your key management system, and use the same master key on all replicas (otherwise replicas can't decrypt private keys generated by each other):
// aesGCMProtector encrypts private key material with AES-256-GCM.
type aesGCMProtector struct{ aead cipher.AEAD }
func newAESGCMProtector(masterKey []byte) (*aesGCMProtector, error) {
block, err := aes.NewCipher(masterKey) // 32 bytes → AES-256
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &aesGCMProtector{aead: aead}, nil
}
// Create satisfies both dp.DataProtectionProvider and dp.DataProtector.
func (p *aesGCMProtector) Create(string) dp.DataProtector { return p }
func (p *aesGCMProtector) Protect(plain []byte) []byte {
nonce := make([]byte, p.aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
panic(fmt.Errorf("read nonce: %w", err))
}
return p.aead.Seal(nonce, nonce, plain, nil)
}
func (p *aesGCMProtector) Unprotect(sealed []byte) ([]byte, error) {
n := p.aead.NonceSize()
if len(sealed) < n {
return nil, errors.New("ciphertext too short")
}
return p.aead.Open(nil, sealed[:n], sealed[n:], nil)
}Step 2: construct the manager and inject the adapters:
// managedSigning adapts the manager into a signing credential store.
type managedSigning struct{ m *keymgmt.Manager }
func (s managedSigning) GetSigningCredentials(ctx context.Context) (*tokens.SigningCredentials, error) {
candidates, err := s.m.SigningCandidates(ctx) // already sorted by priority; the first is preferred
if err != nil {
return nil, err
}
if len(candidates) == 0 {
return nil, keymgmt.ErrNoSigningKey
}
return candidates[0].Credential, nil
}
// managedValidation adapts the manager into a validation key store (the JWKS source).
type managedValidation struct{ m *keymgmt.Manager }
func (v managedValidation) GetValidationKeys(ctx context.Context) ([]*wicketmodels.SecurityKeyInfo, error) {
return v.m.ValidationKeys(ctx)
}
func useManagedKeys(cfg *di.ServerConfig, records keymgmt.RecordStore, masterKey []byte, logger *slog.Logger) (*keymgmt.Manager, error) {
protector, err := newAESGCMProtector(masterKey)
if err != nil {
return nil, err
}
manager := keymgmt.NewManager(keymgmt.Config{
Enabled: true,
AutoCreate: true,
AllowedAlgorithms: []string{keymgmt.AlgRS256},
DefaultAlgorithm: keymgmt.AlgRS256,
// Leave durations at zero to use the defaults: 90-day rotation, 5-minute warm-up, 14-day retention
}, records, protector, nil, nil, logger)
cfg.SetSigningCredentialStores([]storage.SigningCredentialStore{managedSigning{manager}})
cfg.SetValidationKeysStores([]storage.ValidationKeysStore{managedValidation{manager}})
cfg.SetRecordStore(records) // same instance: satisfies Freeze's check for the key record store
return manager, nil
}Notes:
- When
clockandentropypassed tokeymgmt.NewManagerarenil, the system clock andcrypto/randare used; whenrecordsisnil, it falls back to an in-process memory store. - If the protector is
nilor can't round-trip data, the manager refuses to create keys or sign (fail-closed). - Kids generated by the manager are also RFC 7638 thumbprints.
- In this setup, the record store actually used is the instance passed to
NewManager;cfg.SetRecordStoreonly satisfiesFreeze's injection check. Pass the same instance in both places.
Multiple replicas: inject a persistent record store
keymgmt.NewMemoryRecordStore() only works within a single process: a restart loses all keys, each replica creates its own keys, and tokens can't be validated across replicas. With multiple replicas, or whenever keys must survive restarts, you must use a shared, persistent keymgmt.RecordStore, such as store.NewKeyRecordStore(pool, logger) from the official PostgreSQL adapter:
records := pgstore.NewKeyRecordStore(pool, logger) // github.com/gonewx/wicket-pg/store
manager, err := useManagedKeys(cfg, records, masterKey, logger)The injection method is named SetRecordStore (not SetKeyRecordStore). For wiring the adapter, see Persistence.