Skip to content

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 ​

ApproachSuited forRotation
Inject signing credentials manuallyDevelopment; 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 keymgmtYou want Wicket to generate, pre-publish, and rotate keys and retain old public keysAutomatic; 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):

go
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
}
go
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.GenerateKey in-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 / NewMemoryValidationKeysStore only "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's jwks_uri points 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 = false or Endpoints.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:

text
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 ActivationLeadTime remains in the current active key's signing window, a candidate is created early and its public key is published immediately; after a warm-up of max(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:

FieldDefaultNotes
Enabledfalse
AutoCreate*bool in the options; treated as true when unsetWhen false, the manager returns an error if no key is available instead of creating one
RotationInterval90 daysA key's signing lifetime
ActivationLeadTimeEqual to CacheTTL (5 minutes)Candidate warm-up time; must be less than RotationInterval
RetentionDuration14 daysHow long the public key keeps being published after signing stops; should cover the longest token lifetime + clock skew
CacheTTL5 minutesKey snapshot cache duration
ClockSkew5 minutesClock 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
AllowMultipleActivefalse

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

go
// 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:

go
// 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 clock and entropy passed to keymgmt.NewManager are nil, the system clock and crypto/rand are used; when records is nil, it falls back to an in-process memory store.
  • If the protector is nil or 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.SetRecordStore only satisfies Freeze'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:

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

Released under the Apache License 2.0 · Documentation © 2026 Wicket authors