Skip to content

feat: key cache - #1212

Merged
wim07101993 merged 18 commits into
mainfrom
feat/encryption-key-cache
Sep 17, 2026
Merged

wim07101993 merged 18 commits into
mainfrom
feat/encryption-key-cache

Conversation

@wim07101993

@wim07101993 wim07101993 commented Sep 11, 2026 •

Copy link
Copy Markdown
Member

Summary

Project keys are now resolved once and kept in memory, so an authenticated request no longer re-reads its encryption key from the database and re-unwraps it with the master key on every call. Two new settings size the caches: keys.crypter_lru_cache_size and keys.signing_key_lru_cache_size, both defaulting to 1000 entries. Cached entries are dropped when the cache is full and when the server restarts; which key is currently active is never cached, so a future key rotation still takes effect immediately.

uncached 854 µs/op, cached 30 ns/op, from BenchmarkGetCrypter with the database read mocked in both arms so the difference is the unwrap.

Validation

  • go test ./...
  • go test -tags postgres_integration ./...
  • go test -tags sqlite_integration ./...
  • go test -tags spanner_integration ./...

Release notes / changeset

  • Changeset: .changeset/project-key-cache.md — key cache (list @zitadel/server for server changes)

Notes

Also fixes the claim page spending a claim link twice across a remount. Not caused by the cache, but the cache is what made it deterministic: the first completion now commits before the second is sent, so the second always gets the contractual 409 and always lands last, replacing "Project claimed" with "Already claimed". That is what turned cli-journey-e2e red.

@wim07101993
wim07101993 requested a balanced review from Copilot September 11, 2026 10:45
@vercel

vercel Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
nextgen Ready Ready Preview Sep 17, 2026 6:21am UTC
nextgen-docs Ready Ready Preview Sep 17, 2026 6:21am UTC
nextgen-mock-zitadel Ready Ready Preview Sep 17, 2026 6:21am UTC

Request Review

@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

🦋 Changeset detected

Latest commit: cf4f698

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
@zitadel/server Minor
@zitadel/cli Minor
@zitadel/testing Minor
@zitadel/server-linux-x64 Minor
@zitadel/server-linux-arm64 Minor
@zitadel/server-darwin-x64 Minor
@zitadel/server-darwin-arm64 Minor
@zitadel/server-win32-x64 Minor
@zitadel/api Minor
@zitadel/config Minor
@zitadel/components Minor
@zitadel/sdk-core Minor
@zitadel/sdk-next Minor
@zitadel/sdk-nuxt Minor
@zitadel/sdk-react Minor
@zitadel/sdk-vue Minor
@zitadel/sdk-angular Minor
@zitadel/sdk-solid Minor
@zitadel/sdk-svelte Minor
@zitadel/sdk-qwik Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The cache lacks resolved-crypter caching, invalidation, and metrics required by issue #1100.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Introduces bounded in-memory project-key caches to reduce database reads during token encryption, decryption, and signing.

Changes:

  • Adds configurable LRU caches for encryption and signing keys.
  • Routes project key persistence through KeyService.
  • Updates test wiring, mocks, and release notes.
File summaries
File Description
internal/service/project.go Persists project keys through KeyService.
internal/service/project_test.go Mocks key persistence behavior.
internal/service/mocks/service.mock.go Adds generated key-save mocks.
internal/service/keys.go Implements key caches and save methods.
internal/service/keys_test.go Supplies caches to service tests.
internal/service/event_payload_emit_test.go Updates project-event test mocks.
internal/bootstrap/platform/ensure_sqlite_test.go Adds caches to bootstrap tests.
internal/api/integration_test/helpers/key.go Configures integration-test caches.
cmd/server/server.go Constructs caches and sets defaults.
cmd/server/config.go Defines cache configuration fields.
.changeset/project-key-cache.md Documents the shipped behavior.
Review details

Files not reviewed (1)

  • internal/service/mocks/service.mock.go: Generated file

Suppressed comments (1)

internal/service/keys.go:38

  • This description names the wrong key type and contains duplicated prepositions.
// SigningKeyCache caches encryption keys to reduce the load of on the database.
  • Files reviewed: 10/11 changed files
  • Comments generated: 5
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/service/keys.go Outdated
Comment thread internal/service/keys.go Outdated
Comment thread .changeset/project-key-cache.md Outdated
Comment thread internal/service/keys.go Outdated
Comment thread internal/service/keys.go Outdated
Comment thread internal/service/keys.go Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Cache misses currently recurse until stack exhaustion, and required observability and measurement criteria remain incomplete.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Files not reviewed (1)

  • internal/service/mocks/service.mock.go: Generated file

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

internal/service/keys.go:210

  • The new signing-key cache-hit path has no regression test: the added tests cover only crypters. Add a test that calls GetProjectSigningKey twice, asserts one storage read, and verifies project ID and purpose cannot cross-hit so this separate cache contract is protected.

internal/service/keys.go:163

  • On every cache miss this calls getCrypterOfKey again with the same key before any entry is added, so the first token/secret decryption recurses until the process exhausts its stack. Resolve the wrapping key instead (recursing only for a project KEK), then cache the resulting crypter; the direct-master-key branch must also flow through the cache insertion.
	crypter, err := s.getCrypterOfKey(ctx, key)
	if err != nil {
		return nil, err
	}

  • Files reviewed: 10/11 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread cmd/server/server.go
Comment thread internal/service/keys.go Outdated
Comment thread internal/service/keys_test.go

@mridang mridang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a deep look and I really couldn't spot anything out of the orginary. LGTM!

@mridang

mridang commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

A side note:

LRUCrypterCache and LRUSigningKeyCache are the same code with different key/value types: build an LRU, attach metrics.NewCache, record hit/miss in Get, record eviction in Add. Each also comes with its own interface (CrypterCache, SigningKeyCache) and a generated mock.

That's fine at two, but it grows with every cache we add. Each new one means another interface, wrapper, constructor and mock repeating the same instrumentation. We can already see the drift: the schema cache in server.go uses lru.New2Q directly and isn't instrumented at all.

Could we write this once as a generic type instead? Something like:

// internal/cache
type Cache[K comparable, V any] interface {
	Get(key K) (V, bool)
	Add(key K, value V)
}

func NewLRU[K comparable, V any](name string, size int, opts ...metrics.Option) (*LRU[K, V], error)

NewLRU would do the LRU + metrics.NewCache wiring, and Get/Add would record the lookup and eviction. keyService then holds a cache.Cache[crypterCacheKey, op.Crypto], and any future cache (including the schema one) gets metrics just by being created through NewLRU, with no per-cache type or mock.

@wim07101993

Copy link
Copy Markdown
Member Author

Good point, initially there were more differences between them but during development they grew more and more towards each other. Maybe I should consolidate them into one. Will have a look.

@wim07101993

Copy link
Copy Markdown
Member Author

@mridang I made the MeteredLRUCache a central struct, available for other parts. This did eliminate the need for the custom implementations. WDYT?

@wim07101993
wim07101993 enabled auto-merge (squash) September 17, 2026 06:20
@wim07101993
wim07101993 merged commit 5659de3 into main Sep 17, 2026
14 checks passed
@wim07101993
wim07101993 deleted the feat/encryption-key-cache branch September 17, 2026 06:38
@github-project-automation github-project-automation Bot moved this from In review to Done in Engineering Kanban Sep 17, 2026
peintnermax added a commit that referenced this pull request Sep 22, 2026
…#1278)

## Summary

- The claim page could spend its single-use challenge twice — once in
the document hosting the sign-in widget and once in the document the
widget navigated to — so the second `claim/complete` answered 409 and
the page told the developer their project was "Already claimed". This is
what failed `cli-journey-e2e` `claim.spec.ts` on #1247 (run
35725532364), and it is the same symptom #1212 saw; #1212's `globalThis`
gate covers remounts within one document, and this one is two documents.
- Cause, from the Playwright trace: on the terminal step
`<zitadel-login>` retires its back-gesture sentinel (ADR 022) with
`history.back()`. That `popstate` is a navigation to the console router,
which reloads the claim route's loader; by then `sessions/exchange` has
set the cookie, so the loader finds a session, the page swaps the widget
for `CompleteClaim` and POSTs `claim/complete` — 2 ms after
`location.assign` had already requested the next document. The browser
aborts the request on unload, the server has already completed the
claim, and the next document spends again. It's a race against the next
document's HTML response, which is why it shows up on a loaded CI runner
(`--concurrency 5`) and not locally.
- Two independent guards, each with a unit test that fails without it:
- `packages/components` — when the terminal step is about to navigate to
`post-sign-in-url` (`this.completing`), the sentinel is retired in place
with `history.replaceState({ ..., zl: false })` instead of a traversal,
so the host never sees a `popstate`. Steps that stay on screen traverse
exactly as before. The retired entry stays under the destination (a
same-URL destination like the claim page replaces it), which is the same
one-hop stale-sentinel tolerance the code already accepts.
- `apps/console` — the `/claim/` route sets `shouldReload: false`: the
session is read once per document, and no navigation the router observes
while the widget is up can hand the page to `CompleteClaim`. The
intended way back is a full-document navigation, so the initial read is
the only one that matters; `router.invalidate()` still reloads.

## Validation

- `moon run components:test` — `zitadel-login.spec.ts` 48/48; the new
"retires the sentinel in place when the terminal step navigates away"
case fails on the unpatched orchestrator (at the `history.back` call
count) and passes with the fix.
- `moon run console:test` — `claim.spec.tsx` 18/18; the new "does not
spend the challenge when the widget's history sentinel moves the router"
case renders on a real `createBrowserHistory`, replays the widget's
`pushState({ zl: true })` → `history.back()` with a session now
available, and asserts the widget stays up, the loader ran once and
`claim/complete` was never called. Without `shouldReload: false` it
fails with the page on "Project claimed".
- `tsc --noEmit -p apps/console/tsconfig.spec.json` clean; `oxlint`
clean on the four touched files.
- Not run: the `cli-journey-e2e` claim journey itself (needs the release
snapshot); the `full-pr` job on this PR covers it.

## Release notes / changeset

- Changeset: `.changeset/login-terminal-sentinel.md` —
`@zitadel/components` patch: `<zitadel-login>` no longer fires a host
`popstate` when retiring its sentinel on a navigating terminal step. The
console change ships with the server binary and needs no separate entry.

## Notes

- Either guard alone prevents this failure; both are here so the claim
page doesn't depend on widget behaviour and the widget doesn't leak
navigations into any host router, not just ours.
- #1247 (and any other PR that reaches the fresh-app journey) can be
re-run once this merges — the journey builds the release snapshot from
the merge commit.
- Follow-up worth a small PR: `skipPasskeyUpsellIfVisible` in
`claim.spec.ts` still races on a "Skip for now" button that #1169
removed from the default flow, so a claim failure surfaces as a bare 30
s timeout; asserting on the outcome heading and failing fast on "Already
claimed" would have named this bug directly.
- Behavioural nit for reviewers: for a different-URL `post-sign-in-url`
(e.g. `/login` → `/`), the in-place retirement leaves one extra history
entry for the login URL under the destination; a back press lands on the
login page signed in, which redirects. The previous `history.back()`
popped it first. Judged acceptable given the alternative; happy to
discuss.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

This branch was successfully deployed

3 active deployments
Preview – nextgen-docs — cf4f6984 Deployed Sep 17, 2026 by vercel[bot]
Preview – nextgen — cf4f6984 Deployed Sep 17, 2026 by vercel[bot]
Preview – nextgen-mock-zitadel — cf4f6984 Deployed Sep 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

Cache the encryption key chain resolved on every authenticated request

3 participants