Skip to content

Persistence ​

Wicket reads and writes state such as authorization codes, refresh tokens, sessions, and keys through storage ports. This page explains what goes wrong without persistence, and how to complete a production setup with the official PostgreSQL adapter github.com/gonewx/wicket-pg.

Why you need it ​

Port familyWhat it storesConsequence of keeping it only in process memory
storageAuthorization codes, refresh tokens, reference tokens, user consents, persisted grants, device codes, CIBA requestsAll issued refresh tokens become invalid after a restart; with multiple replicas, an authorization code issued on replica A can't be found when the token exchange request hits replica B, and users see random failures
sessionServer-side session recordsEveryone is logged out on restart; sessions aren't shared across replicas
keymgmtAutomatically managed signing keysThe key changes on every restart and issued JWTs can't be validated; each replica creates its own keys

Hosts that only use stateless verification don't need this page. The oidcverify, wechatverify, and phoneverify packages depend on no storage. If all you do is "validate an external token and exchange it for a local identity", you don't need to set up the authorization server, and therefore don't need storage. See Local signature validation and server-side exchange.

The 11 store injection points ​

cfg.Freeze() checks that all of the following 11 seams have been injected:

SeamInjection methodProvided by wicket-pg
authorization code storeSetAuthorizationCodeStoreYes: store.NewAuthorizationCodeStore
refresh token storeSetRefreshTokenStoreYes: store.NewRefreshTokenStore
reference token storeSetReferenceTokenStoreYes: store.NewReferenceTokenStore
user consent storeSetUserConsentStoreYes: store.NewUserConsentStore
persisted grant storeSetPersistedGrantStoreYes: store.NewPersistedGrantStore
device flow storeSetDeviceFlowStoreYes: store.NewDeviceFlowStore
backchannel authentication request storeSetBackchannelAuthenticationRequestStoreYes: store.NewBackchannelAuthenticationRequestStore
session storeSetSessionStoreYes: store.NewSessionStore
key record storeSetRecordStoreYes: store.NewKeyRecordStore
authorization parameters message storeSetAuthorizationParametersMessageStore[Provider]No; handled by the host
distributed cacheSetDistributedCache[Provider]No; handled by the host

If any one is missing and cfg.AllowInMemoryFallback() has not been called, Freeze() panics:

text
panic: store seam "authorization parameters message store" not injected by host and in-memory fallback not declared

So if you inject only the 9 wicket-pg stores and don't handle the other two, Freeze() is guaranteed to fail. See below for how to handle them.

Development: in-memory fallback ​

go
cfg.AllowInMemoryFallback() // local development only; must be called before Freeze
cfg.Freeze()

Once the fallback is declared, any store that wasn't injected is replaced with an in-process memory implementation on first use, and a warn log line is written. Don't call it in production wiring: it treats every uninjected seam the same way, turning "forgot to inject a store" into a latent problem that only shows up with multiple replicas or after a restart.

The official PostgreSQL adapter ​

github.com/gonewx/wicket-pg implements the 9 stores in the table above and ships its own migrations. It requires PostgreSQL 15+ and Go 1.27. The current version is v0.1.3, which depends on wicket v0.1.4.

bash
GOWORK=off go get github.com/gonewx/wicket-pg@v0.1.3

1. Run migrations ​

Migrations ship with the adapter, and the adapter owns the tables. The host calls them explicitly in its startup sequence; they are never triggered automatically. migrations.Up is idempotent (applied versions are recorded in the schema_migrations table):

go
import (
	"github.com/gonewx/wicket-pg/migrations"
	"github.com/jackc/pgx/v5/pgxpool"
)

pool, err := pgxpool.New(ctx, dsn) // the host creates and closes the connection pool
if err != nil {
	log.Fatal(err)
}
defer pool.Close()

if err := migrations.Up(ctx, pool); err != nil {
	log.Fatal(err)
}

To roll back, call migrations.Down(ctx, pool), which rolls back all applied versions.

2. Construct and inject ​

Every constructor has the signature store.NewXxxStore(pool *pgxpool.Pool, logger *slog.Logger); passing nil for logger uses slog.Default(). Keep the adapters in a struct — the cleanup loop and the conformance check both need their concrete types:

go
import (
	"github.com/gonewx/wicket-pg/store"
	"github.com/gonewx/wicket/di"
	servicesmemory "github.com/gonewx/wicket/services/memory"
	"github.com/gonewx/wicket/storage"
)

type pgStores struct {
	authorizationCode *store.AuthorizationCodeStore
	refreshToken      *store.RefreshTokenStore
	referenceToken    *store.ReferenceTokenStore
	userConsent       *store.UserConsentStore
	persistedGrant    *store.PersistedGrantStore
	deviceFlow        *store.DeviceFlowStore
	backchannel       *store.BackchannelAuthenticationRequestStore
	session           *store.SessionStore
	keyRecord         *store.KeyRecordStore
}

func newPGStores(pool *pgxpool.Pool, logger *slog.Logger) *pgStores {
	return &pgStores{
		authorizationCode: store.NewAuthorizationCodeStore(pool, logger),
		refreshToken:      store.NewRefreshTokenStore(pool, logger),
		referenceToken:    store.NewReferenceTokenStore(pool, logger),
		userConsent:       store.NewUserConsentStore(pool, logger),
		persistedGrant:    store.NewPersistedGrantStore(pool, logger),
		deviceFlow:        store.NewDeviceFlowStore(pool, logger),
		backchannel:       store.NewBackchannelAuthenticationRequestStore(pool, logger),
		session:           store.NewSessionStore(pool, logger),
		keyRecord:         store.NewKeyRecordStore(pool, logger),
	}
}

func (s *pgStores) inject(cfg *di.ServerConfig) {
	cfg.SetAuthorizationCodeStore(s.authorizationCode)
	cfg.SetRefreshTokenStore(s.refreshToken)
	cfg.SetReferenceTokenStore(s.referenceToken)
	cfg.SetUserConsentStore(s.userConsent)
	cfg.SetPersistedGrantStore(s.persistedGrant)
	cfg.SetDeviceFlowStore(s.deviceFlow)
	cfg.SetBackchannelAuthenticationRequestStore(s.backchannel)
	cfg.SetSessionStore(s.session)
	cfg.SetRecordStore(s.keyRecord) // note: the method is named SetRecordStore

	// The two seams wicket-pg doesn't cover, declared explicitly (see the next section for why)
	cfg.SetAuthorizationParametersMessageStoreProvider(func() storage.AuthorizationParametersMessageStore {
		return nil // no parameters message store: authorization parameters are encoded directly into the login page's returnUrl
	})
	cfg.SetDistributedCache(servicesmemory.NewMemoryDistributedCache())
}
go
// Illustrative: call order in the startup sequence
pg := newPGStores(pool, logger)
cfg := root.ServerConfig()
pg.inject(cfg)
// ... clients, resources, signing keys ...
cfg.Freeze() // passes without calling AllowInMemoryFallback

This setup has been tested on v0.1.4 + wicket-pg v0.1.3 + PostgreSQL 16: Freeze() passes without declaring an in-memory fallback, re-running migrations has no side effects, and token issuance, introspection, and cleanup all work.

3. Handling the remaining two seams ​

Authorization parameters message store (storage.AuthorizationParametersMessageStore): an optional component. When injected, Wicket stores the authorization request parameters in it and puts only a handle into the login page's returnUrl; when it is nil, the parameters are encoded directly into returnUrl as a query string. Wicket provides no in-memory implementation of it — under the in-memory fallback in development it is nil anyway. Injecting a provider that returns nil behaves the same as development and satisfies Freeze's check. If your authorization request parameters are long and returnUrl exceeds your gateway's limits, implement the interface yourself (three methods: Write / Read / Delete), and have the host close it — the container doesn't release this seam.

Distributed cache (services.DistributedCache): the interface has only three methods, Get, Set, and Remove. In v0.1.4 it is used only for rate-limiting device code polling. If you inject services/memory.NewMemoryDistributedCache(), each replica has its own copy and rate limits are computed per replica. If you need strict device code rate limiting across multiple replicas, implement the interface with Redis or similar and inject that.

4. Verify adapter conformance (optional) ​

Every wicket-pg store exposes ConformsTo(), which returns the version of the conformance suite it passes. Comparing it with Wicket's suite versions at startup prevents a mismatch between the adapter and Wicket versions:

go
import (
	"github.com/gonewx/wicket/keymgmt/keymgmttest"
	"github.com/gonewx/wicket/session/sessiontest"
	"github.com/gonewx/wicket/storage/storagetest"
)

func (s *pgStores) checkConformance() error {
	checks := []struct{ name, got, want string }{
		{"authorization code", s.authorizationCode.ConformsTo(), storagetest.SuiteVersion},
		{"refresh token", s.refreshToken.ConformsTo(), storagetest.SuiteVersion},
		{"reference token", s.referenceToken.ConformsTo(), storagetest.SuiteVersion},
		{"user consent", s.userConsent.ConformsTo(), storagetest.SuiteVersion},
		{"persisted grant", s.persistedGrant.ConformsTo(), storagetest.SuiteVersion},
		{"device flow", s.deviceFlow.ConformsTo(), storagetest.SuiteVersion},
		{"backchannel", s.backchannel.ConformsTo(), storagetest.SuiteVersion},
		{"session", s.session.ConformsTo(), sessiontest.SuiteVersion},
		{"key record", s.keyRecord.ConformsTo(), keymgmttest.SuiteVersion},
	}
	for _, c := range checks {
		if c.got != c.want {
			return fmt.Errorf("adapter %s conforms to %q, wicket requires %q", c.name, c.got, c.want)
		}
	}
	return nil
}

Expired-data cleanup is driven by the host ​

Wicket starts no background goroutines, and there is no single root.CleanupExpired method. Expiration is always decided on the Wicket core side (adapters don't filter by time in their queries), so cleanup only reclaims space and doesn't affect correctness. The reclaim entry points are on the individual stores:

  • The 7 stores of the storage family: RemoveExpired(ctx, cutoff time.Time) (int, error)
  • The session store: DeleteExpired(ctx, cutoff time.Time) (int, error)
  • The key record store: the interface has no time-based reclaim method, and the key manager doesn't delete records either; retired key records are kept indefinitely. If you need to clean them up, the host must do it itself
go
type expirer interface {
	RemoveExpired(ctx context.Context, cutoff time.Time) (int, error)
}

func (s *pgStores) cleanup(ctx context.Context, now time.Time, logger *slog.Logger) {
	stores := map[string]expirer{
		"authorization_code": s.authorizationCode,
		"refresh_token":      s.refreshToken,
		"reference_token":    s.referenceToken,
		"user_consent":       s.userConsent,
		"persisted_grant":    s.persistedGrant,
		"device_flow":        s.deviceFlow,
		"backchannel":        s.backchannel,
	}
	for name, st := range stores {
		n, err := st.RemoveExpired(ctx, now)
		if err != nil {
			logger.Error("cleanup failed", "store", name, "err", err)
			continue
		}
		logger.Debug("cleanup done", "store", name, "removed", n)
	}
	if n, err := s.session.DeleteExpired(ctx, now); err != nil {
		logger.Error("cleanup failed", "store", "session", "err", err)
	} else {
		logger.Debug("cleanup done", "store", "session", "removed", n)
	}
}

// runCleanup runs in the host's own goroutine and exits when ctx is canceled.
func runCleanup(ctx context.Context, s *pgStores, every time.Duration, logger *slog.Logger) {
	t := time.NewTicker(every)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case now := <-t.C:
			cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
			s.cleanup(cctx, now, logger)
			cancel()
		}
	}
}
go
// Illustrative: start the cleanup loop in the startup sequence
cleanupCtx, cancelCleanup := context.WithCancel(context.Background())
go runCleanup(cleanupCtx, pg, 10*time.Minute, logger)
// On shutdown: cancelCleanup()

In a multi-replica deployment, it is safe for every replica to run cleanup (deletes are idempotent), but it produces duplicate database load. You can run it on only one replica, or use a database advisory lock for leader election.

Releasing resources ​

  • The host owns the connection pool. The wicket-pg stores don't implement Close, and root.Close() doesn't close the pgxpool.Pool. The host calls pool.Close() itself after root.Close().
  • If a store the host implemented itself implements io.Closer, root.Close() calls its Close in reverse order (except the authorization parameters message store, which is always closed by the host).

For the shutdown order, see Embedding in a host application.

Writing adapters for other backends ​

If you need another backend such as MySQL or Redis, implement the ports against the conformance suites rather than writing one ad hoc in the host. For the key points (absence and failure must be distinguishable, writes are insert-only by default, version-number concurrency control, expiration decisions stay on the core side, etc.), see Writing a storage adapter and Storage ports.

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