Skip to content

feat(auth): pre-persist registration webhook so rejected signups never leave orphaned Kratos identities - #503

Merged
islandbitcoin merged 6 commits into
mainfrom
feat/kratos-preregistration-hook
Sep 2, 2026
Merged

feat(auth): pre-persist registration webhook so rejected signups never leave orphaned Kratos identities#503
islandbitcoin merged 6 commits into
mainfrom
feat/kratos-preregistration-hook

Conversation

@islandbitcoin

@islandbitcoin islandbitcoin commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Why

On 2026-09-01 every api replica crash-looped on requests from identities that exist in Kratos but have no Mongo account (#499 covers the crash side). Those orphans are created by the registration webhook itself: http://api:4002/kratos/registration is configured response.parse: false, and in Kratos v1.0.0 (what prod runs) that is a post-persist hook — the identity is committed before the api is even called. Any rejection inside createAccountWithPhoneIdentifier strands it. From today's api logs, in a few hours:

  • DuplicateKeyForPersistError × 26 (a users document already holding the phone)
  • IbexError × 13
  • InvalidCarrierTypeForPhoneMetadataError × 3 (a policy rejection raised after the identity was already committed)

plus any pod dying mid-hook.

Kratos mechanics (verified in ory/kratos at v1.0.0)

selfservice/hook/web_hook.go:

func (e *WebHook) ExecutePostRegistrationPrePersistHook(...) error {
	if !(gjson.GetBytes(e.conf, "can_interrupt").Bool() || gjson.GetBytes(e.conf, "response.parse").Bool()) {
		return nil
	}
	...
func (e *WebHook) ExecutePostRegistrationPostPersistHook(...) error {
	if gjson.GetBytes(e.conf, "can_interrupt").Bool() || gjson.GetBytes(e.conf, "response.parse").Bool() {
		return nil
	}

So response.parse: true (or the deprecated can_interrupt: true) moves the hook before persistence. parseWebhookResponse then defines the contract: status ≥ 400 must carry a JSON body {"messages":[{"instance_ptr":"#/traits/phone","messages":[{"id":<int>,"text":"…","type":"error"}]}]} and aborts the flow with those messages (nothing is persisted); a 200 must be JSON ({} is fine) or a 204; a non-JSON body on either path fails the registration with an opaque "webhook failed". response.ignore must stay false on that hook. In the pre-persist hook ctx.identity.id is the nil uuid (identity.NewIdentity() sets ID: uuid.Nil; the persister assigns the real id).

Why two hooks, not one

Moving the whole account write pre-persist would create the inverse orphan: a Mongo account (and a users document holding the phone) whose Kratos persist then fails — e.g. two concurrent registrations for the same number — which locks that phone out of ever registering. So:

  1. New pre-persist hook POST /kratos/preregistration (response.parse: true) — validation only, no writes: callback secret, schema, phone parse, carrier metadata (PhoneMetadataValidator), and "no users document already binds this phone" (the DuplicateKey source). identity_id is never inspected.
  2. Existing post-persist /registration hook unchanged — still creates the account once the identity exists. What it can still leave behind (infra failure mid-hook) is what fix(auth): never crash the api on an orphaned kratos identity; self-heal missing accounts #499's session self-heal repairs.

This is the design the team sketched in dev/ory/kratos.yml (the commented preregistration hook).

Changes

  • src/servers/event-handlers/kratos.ts — new route POST /kratos/preregistration; always JSON. /registration untouched.
  • src/app/authentication/validate-preregistration-payload.ts — read-only app function.
  • src/domain/authentication/registration-payload-validator.tsPreRegistrationPayloadValidator (no identity id); the phone/metadata checks are shared with the existing validator, whose behaviour is unchanged.
  • src/domain/authentication/kratos-hook-messages.ts — the hook contract: message ids, texts, body builder, and the id extractor used on the client side.
  • src/services/kratos/registration-flow-error.ts — when Kratos hands the rejected flow back as a 400, map our ids back to domain errors: 4100002PhoneAlreadyRegisteredError (INVALID_INPUT, sign-up wording: "This phone number is already registered. Contact support if you can't sign in" — deliberately not PhoneAlreadyExistsError, whose "one phone per account" text belongs to the add-phone-to-account flow); 4100001PhoneNotAllowedForRegistrationError; 4100003 / 4100401 / 4100500RegistrationHookFailedError (ErrorLevel.Critical: malformed hook payload, bad callback secret or repository failure is a deploy defect, so it goes to the unexpected-error catch-all and is never reported as a phone-policy answer); any other 400 keeps today's LikelyUserAlreadyExistError. Wired into createIdentityWithSession and createIdentityWithCookie (createIdentityNoSession uses the admin API, which runs no self-service hooks, and is left alone).
  • src/graphql/error-map.tsPhoneNotAllowedForRegistrationError → validation error "This phone number can't be used to sign up"; PhoneAlreadyRegisteredError → validation error "This phone number is already registered. Contact support if you can't sign in" (both INVALID_INPUT, neither falls into the retry-and-contact-support catch-all); RegistrationHookFailedError joins the unexpected-error catch-all.
  • dev/ory/kratos.yml — real pre-registration hook entry, before the existing one.
  • docker-compose.ymlintegration-tests gets the bats-tests network alias (and depends_on: kratos) so the compose Kratos can reach the hook routes the integration suite serves from the test process.
  • quickstart/bin/splice-kratos-preregistration-hook.sh + quickstart/bin/re-render.sh + quickstart/dev/ory/kratos.yml — the quickstart copy is not derived from dev/ory/kratos.yml: re-render.sh vendir-syncs upstream galoy at the pinned ref (which still ships the hook commented out) and only rewrites hosts, and both Quickstart CI and make smoke-env-up run make re-render before boot, so a hand edit is lost on the next render. The splice runs after the host rewrite, inserts the response.parse: true hook ahead of /registration, retires upstream's commented-out draft, is a no-op when the hook is already there, and fails the render (exit 1) when the /registration anchor is missing or laid out differently. The rendered file is regenerated and committed; only its hooks list changed.

Route contract

Case Status Body
pass 200 {}
phone unparsable / carrier metadata invalid 400 id 4100001 "This phone number can't be used to sign up."
phone already bound to a users document 400 id 4100002 "This phone number is already registered."
hook payload malformed (missing phone/schema, wrong schema) 400 id 4100003 "Sign-up request was invalid. Please try again."
bad callback secret 401 id 4100401 "Sign-up is temporarily unavailable."
unexpected error (e.g. Mongo down) 500 id 4100500 "Sign-up is temporarily unavailable. Please try again."

Every non-200 body is {"messages":[{"instance_ptr":"#/traits/phone","messages":[{"id":…,"text":…,"type":"error"}]}]}.

Deploy order (matters)

  1. Ship this api image first. The route must exist before Kratos is told to call it.
  2. Then the config PRs (charts values.yaml and deployments tf-modules/flash/kratos-postgres/postgres-values.tmpl.yaml): add the web_hook for http://api:4002/kratos/preregistration with response: { parse: true } before the existing /registration hook. Applying the config first would fail every registration with "webhook failed".
  3. TEST before prod.

Tests

Test Suites: 229 passed, 229 total
Tests:       3 skipped, 2474 passed, 2477 total
tsc --noEmit -p tsconfig.d.json: exit 0
tsc --noEmit: only the 2 pre-existing Express `Application` errors in graphql-server.ts / graphql-admin-server.ts
eslint: exit 0 on all 21 changed .ts files
typos: exit 0

Unit — new: preregistration-payload-validator.spec.ts (incl. the nil-uuid payload), kratos-hook-messages.spec.ts, validate-preregistration-payload.spec.ts, servers/event-handlers/kratos-preregistration-route.spec.ts (exact bodies for 200/400×3/401/500), services/kratos/registration-flow-error.spec.ts (all five ids, the unmapped-400 fallback, non-400 passthrough), dev/kratos-registration-hooks.spec.ts (both dev/ory/kratos.yml and quickstart/dev/ory/kratos.yml run the pre-persist hook before the post-persist one with the session hook last — fails on the previous rendered quickstart file — plus the splice script on upstream's input, idempotency and both refusal paths); regression case in error-map.spec.ts.

Integration — test/flash/integration/authentication/kratos-preregistration-hook.spec.ts drives the real self-service registration flow against the compose Kratos v1.0.0 with the api's hook routes served from the test process, and asserts against Kratos' admin API what was stored: a phone already bound to a users document is refused as PhoneAlreadyRegisteredError and no identity exists afterwards; carrier metadata the api rejects (carried through transient_payloadbody.jsonnet) is refused as PhoneNotAllowedForRegistrationError, again with nothing persisted; an accepted sign-up still gets its account from the post-persist hook.

Quickstart — make re-render (ytt v0.55.1, vendir v0.46.1, galoy 6906f1b) reproduces the committed quickstart/dev/ory/kratos.yml; the splice gives byte-identical output under BSD awk (macOS) and mawk (ubuntu 24.04); Kratos v1.0.0 boots on the rendered config (config validated at startup, admin /health/ready ok, registration flow initialised).

Follow-ups

  • Config PRs for charts + deployments (same route path and contract as above).
  • Stale users documents (phone bound to a deleted identity) now get a clean "already registered" instead of an orphan; a reconcile for those is separate.
  • InvalidCarrierTypeForPhoneMetadataError could also be checked in isAllowedToOnboard before the flow is even started; this PR makes the hook the safety net either way.
  • make re-render on the pinned galoy ref also rewrites quickstart/galoy/**, quickstart/dev/apollo-federation/router.yaml, quickstart/graphql/public/schema.graphql and quickstart/vendir.lock.yml away from what is committed (pre-existing drift, reverted here to keep this PR to the hook); worth a separate re-sync so CI and the checked-in tree agree.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8

bobodread876 and others added 5 commits September 1, 2026 15:15
…r leave orphaned Kratos identities

The after-registration web_hook runs with `response.parse: false`, which in
Kratos v1.0.0 is a post-persist hook: the identity is committed before the api
is called, so every rejection inside createAccountWithPhoneIdentifier
(DuplicateKeyForPersistError, IbexError, InvalidCarrierTypeForPhoneMetadataError)
or a pod dying mid-hook leaves a logged-in identity with no account. Those
orphans crash-looped every api replica on 2026-09-01.

Add a validation-only pre-persist hook, POST /kratos/preregistration, for a
web_hook configured `response.parse: true`: Kratos then calls it before
persisting, and a 4xx with the `messages` body aborts the sign-up with nothing
written. It checks the callback secret, schema, phone, carrier metadata and
that no users document already binds the phone; it never writes and never
inspects identity_id (the nil uuid at that point). The existing post-persist
/registration hook still creates the account, so a failed Kratos persist can
never strand a Mongo account either.

When Kratos hands the rejected flow back as a 400, map our message ids to
PhoneAlreadyExistsError / PhoneNotAllowedForRegistrationError instead of the
blanket LikelyUserAlreadyExistError; unrecognised 400s keep that reading.

Deploy the api before the charts/deployments hook config, and TEST first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
- PhoneAlreadyRegisteredError: the hook's "already registered" id no longer
  maps to PhoneAlreadyExistsError, whose GraphQL text ("one phone per
  account") was written for the add-phone flow. Sign-up callers have no
  account; the new error reads "This phone number is already registered.
  Contact support if you can't sign in".
- RegistrationHookFailedError (Critical, KratosError): ids 4100003/4100401/
  4100500 mean Kratos config and the api disagree or infra is down. They now
  land in the unexpected-error catch-all under that name instead of being
  reported as a phone-policy answer or as "user already exists".
- Hook route: RegistrationPayloadValidationError logs at error, not warn.
- Carrier errors now extend PhoneMetadataValidationError, so the route's
  parent-class case is real instead of dead.
- kratos-hook-messages: state the actual collision argument (Kratos v1.0.0
  text/id.go allocates 10000-wide blocks up to 4070000 and 5000000; nothing at
  4100000), and pin the block in a test.
- validate-preregistration-payload.spec: assert the repository's update is
  never called instead of inspecting the mock's own shape.
- Drop the duplicate RegistrationPayloadValidator describe; the phoneMetadata
  round-trip and invalid-carrier cases move to its own spec.
- Integration: drive the real self-service registration against the compose
  Kratos with the hook routes served from jest. Asserts a phone bound to a
  stale users doc and invalid carrier metadata are refused with the mapped
  domain error and leave no identity behind, and that an accepted sign-up
  still gets its account from the post-persist hook. Adds kratos to
  integration-deps and a bats-tests network alias so Kratos can reach the
  suite in CI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
…-rendered kratos.yml

quickstart/dev/ory/kratos.yml is not derived from dev/ory/kratos.yml: re-render.sh
vendir-syncs upstream galoy at the pinned ref and only rewrites the hosts, and
both the Quickstart CI job and `make smoke-env-up` run `make re-render` before
boot. Upstream still ships the /kratos/preregistration hook commented out, so
the one environment that runs the real api container against a real Kratos
kept post-persist-only hooks and kept minting the orphaned identities this
branch exists to stop; a hand edit of the rendered file is overwritten on the
next re-render.

- quickstart/bin/splice-kratos-preregistration-hook.sh: after the host rewrite,
  insert the `response.parse: true` web_hook ahead of the /registration hook and
  retire upstream's commented-out draft. No-op when the hook is already there;
  exits 1 when the /registration anchor is missing or laid out differently, so
  a re-render can never silently drop the hook. POSIX awk/sed, verified on BSD
  awk (macOS) and mawk (ubuntu).
- quickstart/bin/re-render.sh: call it.
- quickstart/dev/ory/kratos.yml: regenerated with `make re-render` (ytt v0.55.1,
  vendir v0.46.1, galoy 6906f1b); only the hooks list changed. Kratos v1.0.0
  boots on it.
- test/flash/unit/dev/kratos-registration-hooks.spec.ts: both dev configs must
  run the pre-persist hook before the post-persist one and the session hook
  last (fails on the previous rendered file), plus the splice script's
  behaviour on upstream's input, idempotency and both refusal paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
…t instead of calling it present

Round-3 review finding: the splice script's no-op branch only checked that a
`url: .../kratos/preregistration` line existed. If upstream ever ships that
hook with `parse: false` (post-persist), the script would report "already
present" and Quickstart would run with the orphan-creating shape while every
check stays green. The no-op now also requires `parse: true` within four
lines of that url; anything else exits 1 with a message that says what to
fix. Spec case flips the spliced hook's own parse line (not the comment) and
asserts the refusal leaves the file untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
@islandbitcoin

Copy link
Copy Markdown
Contributor Author

Simon review: 3 rounds. Round 1 (2 blockers, 5 should-fixes) and round 2 (2 should-fixes) applied in 6f55b1a and 6c6f199 by the fix pass; round 3's last should-fix (the splice script's no-op branch accepting a non-pre-persist hook) in the commit above. Full unit suite on the branch: 229 suites / 2,474 tests green; tsc only the 2 pre-existing Express errors; eslint clean.

🤖 Generated with Claude Code

# Conflicts:
#	test/flash/unit/graphql/error-map.spec.ts
@islandbitcoin
islandbitcoin merged commit 953ccec into main Sep 2, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants