Organization Access Flows
How users discover, join, switch, and leave organizations. This document is the single source of truth for all org access user journeys — from first contact to daily use.
Related ADRs: 014 Registration Workflows, 015 Context Resolution, 016 Platform Tenant
Overview
There are six distinct user journeys:
| # | Flow | Who | Surface | Result |
|---|---|---|---|---|
| 1 | Join open org | Anyone | Webapp / Native app | Auto-join, immediate access |
| 2 | Join by-request org | Anyone | Webapp / Native app | Request submitted, admin approves |
| 3 | Join invite-only org | Invited user | Webapp / Native app | Accept invite, immediate access |
| 4 | Switch org | Existing member | Webapp / Native app | Change context, no re-auth |
| 5 | Register a church | Church leader | Backend (/register) | New org created, leader is admin |
| 6 | Leave an org | Member | Webapp / Native app | Membership removed |
Key Concepts
Organization is always present
In the user-facing app (webapp + native), an organization context is always established before any authenticated API call:
| Platform | How org is determined | User choice? |
|---|---|---|
| Webapp | Subdomain: {slug}.churchapp.app → GET /api/v1/organizations/resolve/{slug} → organizationId | No |
| Native app (denomination) | Hardcoded tenant → org picker within that tenant | Yes |
| Native app (platform) | Login → GET /api/v1/me/organizations → org picker | Yes |
Registration mode
Every organization has a registrationMode field:
| Mode | Behavior for new users | Admin action needed? |
|---|---|---|
open | Auto-join as member on first API call | No |
by_request | Request submitted, user waits for approval | Yes — approve or reject |
invite_only | Cannot join without invitation | Yes — send invite first |
Default: open. Configurable per org in admin UI.
Authentication is identity + context
Every authenticated API request carries two pieces:
Authorization: Bearer <zitadel-jwt> → proves identity (who you are)
X-Organization-Id: <org-uuid> → declares context (which org)
The tenant is always derived server-side from org.tenant_id.
Flow 1: Join Open Org
Scenario: A person opens icf-bern.churchapp.app (or selects ICF Bern in the native app) for the first time. The org has registrationMode: open.
Sequence
┌─────────┐ ┌────────────┐ ┌─────────┐ ┌─────────┐
│ User │ │ Frontend │ │ Zitadel │ │ Backend │
└────┬────┘ └─────┬──────┘ └────┬────┘ └────┬────┘
│ Opens app/subdomain│ │ │
│────────────────────>│ │ │
│ │ GET /api/v1/organizations/resolve/icf-bern
│ │──────────────────────────────────────────>│
│ │ { organizationId, tenantId, name, │
│ │ registrationMode: "open", ... } │
│ │<─────────────────────────────────────────│
│ │ │ │
│ │ Show org landing │ │
│ │ (name, logo, "Join") │ │
│<────────────────────│ │ │
│ │ │ │
│ Taps "Sign in" │ │ │
│────────────────────>│ │ │
│ │ OIDC redirect (PKCE) │ │
│ │─────────────────────>│ │
│ Authenticates │ │ │
│────────────────────────────────────────────>│ │
│ │ Callback + JWT │ │
│ │<─────────────────────│ │
│ │ │ │
│ │ GET /api/v1/me │
│ │ Authorization: Bearer <jwt> │
│ │ X-Organization-Id: <icf-bern-uuid> │
│ │──────────────────────────────────────────>│
│ │ │ │
│ │ Authenticator: │
│ │ 1. Validate JWT │
│ │ 2. Resolve org → derive tenantId │
│ │ 3. Provision User (if new) │
│ │ 4. Check OrgMembership → none found │
│ │ 5. registrationMode = "open" │
│ │ 6. Auto-create OrgMembership (member) │
│ │ 7. Return SecurityUser with org context │
│ │ │ │
│ │ { id, email, displayName, orgRole: "member" }
│ │<─────────────────────────────────────────│
│ │ │ │
│ Home screen loaded │ │ │
│<────────────────────│ │ │
Key details
- The auto-join happens inside the authenticator (
ZitadelBearerAuthenticator) — there’s no separate “join” endpoint - The user doesn’t see a “joining” step — it’s seamless
- The resolve endpoint is public (no auth needed) — frontend calls it before the user logs in
- The resolve response includes
registrationModeso the frontend can show appropriate UI before login (e.g. “Open community — sign in to join” vs. “Invite only — contact an administrator”) - If the user already has a Zitadel account, they just sign in. If not, they create one at Zitadel first
Frontend behavior
- Before login: show org landing page with name, logo, description
- If
registrationMode: open: show “Sign in to join” button - After successful
/mecall: navigate to Home tab
Flow 2: Join By-Request Org
Scenario: A person wants to join an org with registrationMode: by_request. They must request access and wait for admin approval.
Sequence
User opens app/subdomain
→ Frontend resolves org (public endpoint)
→ registrationMode: "by_request"
→ Show: "This community requires approval. Sign in to request access."
User signs in via Zitadel
→ Frontend calls GET /api/v1/me with X-Organization-Id
→ Authenticator:
1. Validate JWT
2. Resolve org → derive tenantId
3. Provision User (if new)
4. Check OrgMembership → none found
5. registrationMode = "by_request"
6. Return 403 { error_code: "membership_pending_approval" }
→ Frontend shows: "Your request has been submitted. You'll be notified when approved."
What needs to be built (not yet implemented)
Backend:
OrgJoinRequestentity — tracks pending requests- Fields:
id,userId,organizationId,tenantId,status(pending/approved/rejected),message(optional user note),reviewedBy,reviewedAt,timestamps
- Fields:
POST /api/v1/organizations/{id}/join-request— submit a join request (called by authenticator or explicitly)GET /api/v1/admin/organizations/{id}/join-requests— list pending requests (admin)POST /api/v1/admin/organizations/{id}/join-requests/{requestId}/approve— approve (creates OrgMembership)POST /api/v1/admin/organizations/{id}/join-requests/{requestId}/reject— reject (with optional reason)- Domain events:
OrgJoinRequested,OrgJoinRequestApproved,OrgJoinRequestRejected - Notification to org admins when a new request arrives
- Notification to user when request is approved/rejected
Frontend:
- Request status screen: “Pending approval” with org info
- Push notification when approved → deep link to org home
- Admin: “Join Requests” section in org management with approve/reject actions
Open questions
- Should the authenticator auto-create the join request on 403, or should the frontend explicitly POST to a join-request endpoint after getting the 403?
- Recommendation: The authenticator returns 403, the frontend then calls a dedicated
POST /join-requestendpoint. This keeps the authenticator simple and lets the user optionally add a message (“Hi, I’m new to the church, referred by…”).
- Recommendation: The authenticator returns 403, the frontend then calls a dedicated
- Can a user with a pending request access any content? No — they have no membership, so all org-scoped endpoints return 403.
- Can a user re-request after being rejected? Yes, after a cooldown period (e.g. 7 days).
Flow 3: Join Invite-Only Org
Scenario: An org admin sends an invitation. The invited person accepts and gains membership.
Invitation creation (admin side)
Admin opens org settings → Members → "Invite"
→ Enters email address (or generates a shareable link / QR code)
→ Backend creates OrgInvitation:
{ id, organizationId, tenantId, email (optional), token, role: "member",
expiresAt, createdBy, status: "pending" }
→ If email provided: send email with invite link
→ If no email: return shareable link / QR code
Invitation acceptance (invitee side)
Invitee clicks link: churchapp.app/invite/{token}
→ Frontend resolves invitation (public endpoint):
GET /api/v1/invitations/{token}
→ { organizationId, organizationName, invitedBy, expiresAt, status }
→ Show: "You've been invited to join [Org Name]"
→ User signs in via Zitadel
→ Frontend calls: POST /api/v1/invitations/{token}/accept
Authorization: Bearer <jwt>
→ Backend:
1. Validate token (exists, not expired, not already used)
2. Provision User in tenant (if new)
3. Create OrgMembership with invited role
4. Mark invitation as accepted
5. Return { organizationId, role }
→ Frontend sets X-Organization-Id, navigates to Home
What needs to be built (not yet implemented)
Backend:
OrgInvitationentity — tracks invitations- Fields:
id,organizationId,tenantId,email(nullable — for link-based invites),token(unique, URL-safe),role,expiresAt,createdBy,acceptedBy,status(pending/accepted/expired/revoked),timestamps
- Fields:
POST /api/v1/admin/organizations/{id}/invitations— create invitation (admin)GET /api/v1/invitations/{token}— resolve invitation (public, no auth)POST /api/v1/invitations/{token}/accept— accept invitation (auth required)DELETE /api/v1/admin/organizations/{id}/invitations/{id}— revoke invitation (admin)- Domain events:
OrgInvitationCreated,OrgInvitationAccepted,OrgInvitationRevoked
Frontend:
- Invite screen: accept/decline with org info
- Admin: “Invitations” section in org management (send, view pending, revoke)
- Share sheet: copy invite link, show QR code
Design decisions
- Link-based vs. email-based: Both. Admin can either enter an email (sends branded email with link) or generate a shareable link (for WhatsApp, printed QR, etc.)
- Token format: URL-safe, 32-char random string. No UUIDs in invite URLs.
- Expiration: Default 7 days, configurable by admin (min 1 day, max 90 days)
- Role on invite: Default
member, admin can chooseadminfor leadership invites - Reusable links: A single link can optionally allow multiple uses (for “share this with your friends” scenarios). Controlled by
maxUsesfield (null = unlimited).
What happens when an uninvited user tries to join
User signs in → GET /api/v1/me with X-Organization-Id
→ Authenticator: no membership, registrationMode = "invite_only"
→ Return 403 { error_code: "invite_required",
error: "This organization is invite-only. Contact an administrator for access." }
→ Frontend shows: "This community is invite-only" with org contact info
Flow 4: Switch Org
Scenario: A user who belongs to multiple orgs (e.g. ICF Zürich and ICF Bern) wants to switch their active org.
Native app flow
User is in ICF Zürich context
→ Taps "More" tab → "Switch organization"
→ App calls: GET /api/v1/me/organizations
Authorization: Bearer <jwt>
→ Returns: [
{ organizationId, name, slug, role, tenantId, tenantName, profileImagePath },
{ organizationId, name, slug, role, tenantId, tenantName, profileImagePath },
...
]
→ Org picker shows list of orgs (grouped by tenant if multi-tenant)
→ User selects ICF Bern
→ Frontend updates X-Organization-Id header for all subsequent requests
→ Refreshes Home screen data with new org context
→ Header updates to show ICF Bern name/logo
Webapp flow
User is on icf-zurich.churchapp.app
→ Taps "Switch organization" in More tab
→ Org picker shows (same as native)
→ User selects ICF Bern
→ Option A: Redirect to icf-bern.churchapp.app (full page navigation)
→ Option B: Stay on same domain, update X-Organization-Id in-memory
→ Recommendation: Option A for webapp (each org has its own subdomain)
What needs to be built
Backend:
GET /api/v1/me/organizations— list all orgs the current user belongs to- Cross-tenant: uses
externalAuthIdto find User rows in ALL tenants, then fetches their OrgMemberships - Response includes: org name, slug, role, tenant info, profile image
- Sorted by: tenant name, then org name
- Referenced in ADR 016 but not yet implemented
- Cross-tenant: uses
Frontend:
- Org picker component (bottom sheet on mobile, dropdown on desktop)
- If user has only 1 org: skip picker, auto-select (per ADR 016)
- If user has orgs in multiple tenants: group by tenant with visual separator
- Store selected org in local state (AsyncStorage on native, memory on web)
- On switch: clear cached data, refresh Home/Events/Community
Key technical details
- No re-authentication needed. The Zitadel JWT stays valid. Only the
X-Organization-Idheader changes. - Cross-tenant switching: If the user switches to an org in a different tenant, the backend resolves a different
Userrow (sameexternalAuthId, differenttenantId). The user’s roles and profile may differ per tenant. - Data isolation: After switching, all API responses return data scoped to the new org. Cached data from the previous org must be cleared.
Org picker UX
┌─────────────────────────────────┐
│ Switch Organization ✕ │
├─────────────────────────────────┤
│ │
│ Church App Platform │ ← tenant name (if multi-tenant)
│ ┌─────────────────────────┐ │
│ │ 🏠 ICF Zürich admin │ │ ← current org highlighted
│ │ 🏠 ICF Bern member │ │
│ │ 🏠 Grace Chapel member │ │
│ └─────────────────────────┘ │
│ │
│ ICF Movement │ ← different tenant
│ ┌─────────────────────────┐ │
│ │ 🏠 ICF Zürich admin │ │ ← same person, different tenant
│ └─────────────────────────┘ │
│ │
└─────────────────────────────────┘
- Show org profile image (or placeholder icon) + name + user’s role
- Current org marked with checkmark or highlight
- Single-tenant users see a flat list (no tenant grouping)
- Tapping an org closes the picker and switches context
Flow 5: Register a Church
Scenario: A church leader wants to create their church on the platform.
Sequence
Leader visits churchapp.app/register (backend, Twig form)
→ Fills in:
- Church name
- Slug (auto-suggested from name, editable)
- Type (church, campus, ministry)
- Address (street, city, postal code, country)
- Description (optional)
→ Clicks "Register"
→ Redirected to Zitadel for authentication (OIDC PKCE flow)
→ Zitadel callback → backend processes:
1. Create Organization (child of platform tenant's root org)
- registrationMode: open (default)
- status: active
2. Provision User in platform tenant (or link existing)
3. Create OrgMembership (role: admin)
4. Set session active_organization_id
→ Redirect to /admin/dashboard
→ Leader sees their org admin panel, ready to:
- Configure registration mode
- Upload logo/branding
- Create groups
- Invite members
Current implementation status
- The
/registerroute, form, andRegisterTenantCommandexist - Creates an org in the default tenant (not a new tenant, per ADR 014)
- Post-registration admin dashboard needs onboarding guidance (not yet designed)
Post-registration admin onboarding (to be designed)
The admin dashboard on first visit should guide the leader through setup:
- Upload church logo / profile image
- Set registration mode (open recommended for most churches)
- Create first groups (suggested templates: “Worship Team”, “Youth”, etc.)
- Share invite link / QR code with members
- Configure service times / recurring events
Flow 6: Leave an Org
Scenario: A user wants to leave an organization they belong to.
Sequence
User opens More tab → Settings (or org info screen)
→ Taps "Leave organization"
→ Confirmation dialog: "Are you sure you want to leave [Org Name]? You'll lose access to all content."
→ User confirms
→ Frontend calls: DELETE /api/v1/me/organizations/{organizationId}
→ Backend:
1. Validate user has membership
2. Check: is this the user's only org? → warn (user must belong to at least one org?)
3. Check: is user the last admin? → prevent (org must have at least one admin)
4. Delete OrgMembership
5. Domain event: OrgMemberRemoved
→ Frontend: switch to another org (if available) or show "no org" state
What needs to be built
Backend:
DELETE /api/v1/me/organizations/{organizationId}— leave an org- Validation: cannot leave if last admin (must transfer admin role first)
- Domain event already exists:
OrgMemberRemoved
Frontend:
- Leave confirmation dialog
- Post-leave: auto-switch to next org or show empty state
Open questions
- Can a user exist with zero org memberships? Recommendation: Yes — the user still has a Zitadel account and can join/be invited to other orgs. The app shows a “Join an organization” screen.
- What about group memberships when leaving an org? Recommendation: Auto-remove all group memberships in that org when the user leaves. Groups are org-scoped, so membership in them is meaningless without org membership.
API Endpoint Summary
Existing (implemented)
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/organizations/resolve/{slug} | Public | Resolve slug → org + tenant info + registrationMode |
| GET | /api/v1/me | Bearer + X-Org | Current user with org context (auto-joins open orgs) |
| GET | /api/v1/organizations | Bearer + X-Org | List orgs in tenant |
| GET | /api/v1/organizations/{id} | Bearer + X-Org | Org detail |
| PUT | /api/v1/organizations/{id} | Bearer + X-Org | Update org (incl. registrationMode) |
Needed (not yet implemented)
| Method | Path | Auth | Description | Flow |
|---|---|---|---|---|
| GET | /api/v1/me/organizations | Bearer (no X-Org) | List all orgs user belongs to (cross-tenant) | 4 |
| POST | /api/v1/organizations/{id}/join-request | Bearer + X-Org | Submit join request | 2 |
| GET | /api/v1/admin/organizations/{id}/join-requests | Bearer + X-Org (admin) | List pending join requests | 2 |
| POST | /api/v1/admin/join-requests/{id}/approve | Bearer + X-Org (admin) | Approve join request | 2 |
| POST | /api/v1/admin/join-requests/{id}/reject | Bearer + X-Org (admin) | Reject join request | 2 |
| POST | /api/v1/admin/organizations/{id}/invitations | Bearer + X-Org (admin) | Create invitation | 3 |
| GET | /api/v1/invitations/{token} | Public | Resolve invitation | 3 |
| POST | /api/v1/invitations/{token}/accept | Bearer | Accept invitation | 3 |
| DELETE | /api/v1/admin/invitations/{id} | Bearer + X-Org (admin) | Revoke invitation | 3 |
| DELETE | /api/v1/me/organizations/{id} | Bearer + X-Org | Leave an org | 6 |
Error Responses
All org access errors use structured JSON responses:
| Scenario | HTTP | error_code | error message |
|---|---|---|---|
| No membership, org is open | — | — | (auto-join, no error) |
| No membership, org is by_request | 403 | membership_pending_approval | Membership requires approval by an administrator. |
| No membership, org is invite_only | 403 | invite_required | This organization is invite-only. Contact an administrator for access. |
| Invalid/missing X-Organization-Id | 401 | — | Missing or invalid X-Organization-Id header. |
| Org not found / archived | 401 | — | Organization not found. |
| User not found, no auto-provision | 401 | account_not_found | Account not found. |
| Invitation expired | 410 | invitation_expired | This invitation has expired. |
| Invitation already used | 409 | invitation_already_used | This invitation has already been accepted. |
| Join request already pending | 409 | request_already_pending | You already have a pending request for this organization. |
| Cannot leave, last admin | 422 | last_admin | Cannot leave — you are the last admin. Transfer the admin role first. |
Implementation Priority
Recommended order for building these flows:
| Priority | What | Why |
|---|---|---|
| P0 | Flow 1 (open join) | Already implemented — the core auto-join flow works |
| P1 | Flow 4 (switch org) — GET /me/organizations | Required for native app to work with multi-org users |
| P1 | Flow 5 (register church) | Already partially implemented, needs polish |
| P2 | Flow 3 (invite-only join) | Most churches will want to invite specific people |
| P2 | Flow 6 (leave org) | Simple to build, needed for user autonomy |
| P3 | Flow 2 (by-request join) | Less common, requires approval workflow + notifications |