8a61aa4f36 Merge commit from fork
* fix(command): enforce permission check when issuing passkey enrollment codes

addUserPasskeyCode - behind CreatePasskeyRegistrationLink via
AddUserPasskeyCode / AddUserPasskeyCodeURLTemplate / AddUserPasskeyCodeReturn -
issued a passkey enrollment code without any authorization check.

The RPC's auth annotation carries user.passkey.write with no org_field, so the
API interceptor only verifies the caller's permission in the org taken from the
caller-supplied x-zitadel-orgid header, never the org that owns the target user.
The gRPC handlers additionally pass an empty resourceOwner, and the write model
lookup is instance-wide, so the code was minted against the victim's aggregate
regardless of org. An org owner of one organization could therefore issue a
passkey enrollment code for a user in another organization, enroll an
attacker-controlled authenticator and take over the account.

Authorize against the target user's actual resource owner, resolved from the
write model, before pushing the code requested event.

The check uses user.passkey.write - the permission the RPC already declares -
rather than user.credential.write used by the sibling RegisterUserPasskey. This
narrows the organization the permission is evaluated in without narrowing the
set of roles that may call the endpoint: IAM_USER_MANAGER holds
user.passkey.write but not user.credential.write, and would otherwise lose the
endpoint instance-wide.

The enrollment code stays a bearer credential by design, so the unauthenticated
login flows redeeming it are unaffected; issuance is the only place this can be
enforced.

Co-Authored-By: Claude Opus 5 <[email protected]>

* test(command): guard cross-org passkey enrollment code issuance

The existing AddUserPasskeyCode* tests all use an allowed permission check, so
none of them fails if the authorization is removed again.

Assert the denial path instead: with the caller passing an empty resourceOwner,
as the v2 gRPC handlers do, the check must receive the target user's real
resource owner resolved from the write model, and no event may be pushed.

Without the check in addUserPasskeyCode all three subtests fail, one of them on
an unexpected Push - a code would actually have been issued.

Co-Authored-By: Claude Opus 5 <[email protected]>

* test(user/v2): simulate cross-org passkey registration link attack

End-to-end reproduction against a live instance: an org owner of one
organization requests a passkey registration link, with the request-header org
pinned to its own org, for a user that lives in another organization. Uses the
return-code medium so a successful response would carry the plaintext code,
making a missing denial impossible to pass silently.

The second subtest pins the other half of the contract: IAM_USER_MANAGER holds
user.passkey.write instance-wide and must keep working across organizations, so
the fix cannot be tightened into user.credential.write without regressing it.

No equivalent test for user/v2beta: it shares the same internal/command layer
and is fixed by the same change, but the API is deprecated, so the duplicate is
deliberately omitted.

Co-Authored-By: Claude Opus 5 <[email protected]>

* refactor(command): generate passkey code only after the permission check

Review feedback: the code ID and the passkey code - the latter costs an extra
eventstore filter for the secret generator config - were produced before the
permission check, so an unauthorized caller still paid for both.

The write model is now built with an empty code ID, which is equivalent for
the lookup: the ID only matches events of an already existing code, and a code
about to be created has none. It is assigned right after the check so the
pushed event is still appended to the write model.

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(command): enforce permission check on v1 passwordless init codes

The deprecated management RPCs AddPasswordlessRegistration and
SendPasswordlessRegistration accept an attacker-controlled user_id and pass the
caller's own organization to the command. That organization only scopes the
write model's read, not the write: for a user of another organization the read
matches nothing, the write model keeps the caller's organization, and on push
the eventstore re-owns the event to the target's real organization
(internal/eventstore/v3/sequence.go). An org admin could therefore mint a
passwordless enrollment link for a user of any other organization and, after
correcting the orgID in the returned link, take the account over.

Same class of flaw as the v2 CreatePasskeyRegistrationLink issue, but a
different command, so the earlier fix does not reach it. Copying that fix
verbatim would not close it either: authorizing against the write model's
resource owner would authorize against the attacker's own organization, where
they legitimately hold the permission. The check is instead handed an empty
resource owner, which makes it resolve the target's real owner from the
eventstore.

The check sits in the two exported commands rather than in the shared inner
function, for two reasons:

  - the RPCs declare different permissions, user.credential.write and
    user.write. Hardcoding one would either lock IAM_USER_MANAGER and
    ORG_USER_MANAGER out of SendPasswordlessRegistration or widen
    AddPasswordlessRegistration to them. Access is preserved exactly.
  - the inner function is also called by importHuman while creating a user,
    whose aggregate has no events yet and whose owner cannot be resolved.

Self management stays allowed, so the auth API's AddMyPasswordlessLink and
SendMyPasswordlessLink are unaffected, as is anonymous redemption of the code
through the login UI - the init code is a bearer credential by design and
issuance is the only place this can be enforced.

Co-Authored-By: Claude Opus 5 <[email protected]>

* test(command): guard cross-org v1 passwordless init code issuance

Asserts the organization handed to checkPermission, not merely that a denial
happened. A caller acting in org1 targeting a user of org2 must be authorized
against org2; a test that only asserted PermissionDenied would pass against the
attacker's own organization, where they hold the permission - which is exactly
how a fix copied from the v2 command would fail.

Also pins the permission per command, so a later attempt to hoist the check
into the shared inner function shows up as roles gaining or losing access, and
asserts that self management still skips the check for the auth API.

Negative controls, both confirmed:
  - authorizing against the caller-supplied resource owner: the two RPC
    subtests fail on org1 != org2.
  - dropping the check: they fail on an unexpected ID generation and an
    unfulfilled resource owner lookup, i.e. a code would have been issued.

Co-Authored-By: Claude Opus 5 <[email protected]>

* test(management): simulate cross-org passwordless registration link attack

Reproduces the attack against a live instance through the deprecated v1
management API, and pins the two access guarantees the fix has to keep.

Verified by running the three builds against a real instance:

  | build                          | cross-org denied | IAM_USER_MANAGER can send |
  |--------------------------------|------------------|---------------------------|
  | no check                       | FAIL, link with  | PASS                      |
  |                                | plaintext code   |                           |
  |                                | returned         |                           |
  | user.credential.write for both | PASS             | FAIL, AUTH-AWfge          |
  | per-RPC permissions (shipped)  | PASS             | PASS                      |

Row 1 is the vulnerability. Row 2 is why SendPasswordlessRegistration keeps
user.write: IAM_USER_MANAGER holds it instance wide, and instance memberships
resolve in every organization, so the stricter permission would take the
endpoint away from that role everywhere.

ORG_USER_MANAGER gets no separate case: it holds the same user.write through an
org-scoped membership, which the same-org subtest already covers. The auth API's
self-service equivalents have no integration test suite; they are covered by the
self-management unit test instead.

Co-Authored-By: Claude Opus 5 <[email protected]>

* chore: trigger checks

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-14 06:11:39 +02:00
2026-08-14 06:11:39 +02:00
2023-07-17 10:08:20 +02:00
2024-07-26 22:39:55 +02:00
2021-02-08 16:48:41 +01:00
2025-04-02 16:53:06 +02:00
2026-02-04 11:51:43 +01:00
2026-06-04 14:33:04 +02:00
2026-02-25 19:36:00 -03:00
2026-06-10 11:53:26 +00:00

ZITADEL Logo ZITADEL Logo

GitHub Workflow Status (with event) GitHub contributors

The Identity Infrastructure for Developers

ZITADEL is an open-source identity and access management platform built for teams that need more than basic auth. Whether you're securing a SaaS product, building a B2B platform, or self-hosting a production IAM stack — ZITADEL gives you everything out of the box: SSO, MFA, Passkeys, OIDC, SAML, SCIM, and a battle-tested multi-tenancy model.

No vendor lock-in. No compromise on control. Just a robust, API-first identity platform you can own.


🏡 Website  |  💬 Chat  |  📋 Docs  |  🧑‍💻 Blog  |  📞 Contact


Why ZITADEL

We built ZITADEL to handle the hardest IAM challenges at scale — starting with multi-tenancy.

ZITADEL FusionAuth Keycloak Auth0/Okta
Open-source ✅ ❌ ✅ ❌
Self-hostable ✅ ✅ ✅ ❌
Infrastructure-level tenants ✅ Instances (High scale) ✅ Tenants 🟡 Realms (Scaling limits) ❌ (Multi-tenant = multi-account)
B2B Organizations ✅ Native & Unlimited 🟡 via Entity Management ✅ (Recent addition) 🟡 (Plan/Account dependent)
Full audit trail ✅ Comprehensive Event Stream* 🟡 Audit logs 🟡 Audit logs 🟡 Audit logs
Passkeys (FIDO2) ✅ ✅ ✅ ✅
Actions / webhooks ✅ ✅ 🟡 via SPI ✅
API-first (gRPC + REST) ✅ 🟡 REST only 🟡 REST only 🟡 REST only
SaaS + self-host parity ✅ ✅ ➖ N/A ➖ N/A

ZITADEL Cloud and self-hosted ZITADEL run the same codebase.

Key differentiators for architects:

  • Relational core, event-driven soul — every mutation is written as an immutable event for a complete, API-accessible audit trail. Unlike systems that log only select activities, ZITADEL provides a comprehensive event stream that can be audited or streamed to external systems via Webhooks.
  • Strict multi-tenant hierarchy — Identity System → Organizations → Projects, with isolated data and policy scoping at multiple levels
  • API-first design — every resource and action is available via connectRPC, gRPC, and HTTP/JSON APIs
  • Zero-downtime updates and horizontal scalability without external session stores

Get Started in 3 Minutes

👉 Quick Start Guide

ZITADEL Self-Hosted

# Docker Compose — up and running in under 3 minutes
curl -LO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.yml \
  && curl -LO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/.env.example \
  && cp .env.example .env \
  && docker compose up -d --wait

Full deployment guides:

Need professional support for your self-hosted deployment? Contact us.

ZITADEL Cloud (SaaS)

Start for free at zitadel.com — no credit card required. Available in US · EU · AU · CH. Pay-as-you-go pricing.


Integrate with the V2 API

ZITADEL exposes every capability over a typed API. Here's how to create a user with the V2 REST API:

curl -X POST https://$ZITADEL_DOMAIN/v2/users/human \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "username": "[email protected]",
    "profile": { "givenName": "Alice", "familyName": "Smith" },
    "email": { "email": "[email protected]", "sendCode": {} }
  }'

Explore the full API reference — including connectRPC and gRPC transports — or jump straight to quickstart examples.


Features

Authentication

Multi-Tenancy

Integration

Self-Service & Admin

Deployment

Track upcoming features on our roadmap and follow our changelog for recent updates.


Showcase

Login V2

Our new, fully customizable login experience — documentation


Adopters & Ecosystem

Used in production by organizations worldwide. See the full Adopters list — and add yours by submitting a pull request.


How To Contribute

ZITADEL is built in the open and welcoming to contributions of all kinds.

Contributors

Made with contrib.rocks.


Security

Security policy: SECURITY.md

Vulnerability Disclosure Policy — how to responsibly report security issues.

Technical Advisories are published for major issues that could impact security or stability in production.

License

AGPL-3.0 — see LICENSING.md for the full licensing policy, including Apache 2.0 and MIT exceptions for specific directories.

S
Description
No description provided
Readme AGPL-3.0
510 MiB
Languages
Go 74.6%
TypeScript 12%
MDX 7.9%
HTML 2.6%
SCSS 1.2%
Other 1.7%