Developer Hub › Authentication & Identity

Authentication & Identity

How the platform owner, a tenant, a tenant's applications, and an application's end users each obtain credentials — and where their roles and policies come from.

The gateway is default-deny. One root onRequest hook (services/api-gateway/src/plugins/authGate.ts) requires a valid tenant JWT for every path unless it is on the public allowlist (auth bootstrap, health, .well-known, metrics, SAML, OAuth callback, signed webhooks), self-guards as admin, or is a WebSocket upgrade. An endpoint you did not explicitly make public is closed. Kill-switch: AUTH_GATE_MODE=enforce|report|off.

The four principals

PrincipalWhoCredentialPresented as
Platform operator You, running ProjexCloud ADMIN_OPS_TOKEN (one shared operator secret) Admin header on /admin/*
Tenant admin
human
LeadFlow's own staff — the tenant owner Password → JWT Authorization: Bearer <jwt>
Tenant application
machine
An app LeadFlow built on the SDKs API key pk_live_… / pk_test_… Authorization: Bearer pk_live_…
App end user
human
LeadFlow's customers — millions of them Password → JWT, scoped to an app_id Authorization: Bearer <jwt>
Both the JWT and the API key arrive in the same header. The gateway tells them apart by the pk_live_ / pk_test_ prefix — there is no X-API-Key header. Sending the key anywhere else is the single most common integration mistake.

Level 1 — Platform operator

Operator routes are not part of the tenant API. They self-guard with ADMIN_OPS_TOKEN and reject a tenant JWT, which is why they are excluded from the published tenant OpenAPI spec: offering them to tenants would advertise calls that can only ever fail.

curl -X POST https://cloud.projexlight.com/admin/federation/profiles \
  -H "Authorization: Bearer $ADMIN_OPS_TOKEN" \
  -H "Content-Type: application/json" -d '{ ... }'

There is exactly one operator secret; the former FEDERATION_ADMIN_TOKEN was consolidated into it. Rotate it at the environment, not per call. Operator endpoints are published separately via openapi-operator.json (node scripts/catalog/build-openapi.js --include-admin).


Level 2 — Tenant signs up

This is the bootstrap call, and the only one that needs no credential. It is a single transaction that creates the entire spine for a new tenant.

POST /api/auth/signup-tenant public

curl -X POST https://cloud.projexlight.com/api/auth/signup-tenant \
  -H "Content-Type: application/json" \
  -d '{
        "email": "ops@leadflow.io",
        "password": "•••••••••",
        "org_name": "LeadFlow",
        "app_id": "leadflow",
        "display_name": "LeadFlow Production",
        "region": "us-east-1"
      }'

In one transaction it writes:

1
identity.person — the human being, independent of any tenant. This row is the root of identity and is never duplicated per app.
2
identity.alias (email, envelope-encrypted) and identity.credential (password) — how that person proves who they are.
3
tenant.orgtenant.apptenant.tenant — the organisation, the application, and the tenant record (isolation_tier S, status trial).
4
identity.tenant_membership — binds the person to the tenant, and carries bu_id and role_template_id.
5
identity.app_identity — that person's identity within that app, unique on (person_id, app_id).
6
profile.band_l2 — the display/profile band, so a portal can greet the user without a second fetch.

Verify the email

POST /api/auth/send-verification-email · POST /api/auth/verify-email · GET /api/auth/verification-status

The alias is written with verified_at NULL. Verification uses a purpose-scoped, short-lived token — deliberately not a session token, so a verification link that leaks cannot be replayed as a login.

Log in and get a token

POST /api/auth/login public

curl -X POST https://cloud.projexlight.com/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{ "email":"ops@leadflow.io", "password":"•••", "tenant_id":"<optional>" }'

Passing tenant_id selects which membership the token is minted against. Omit it and you get a person-level token carrying no tenant claims — useful when a person belongs to several tenants and must choose.

The six-layer JWT

Authority is not a list of scopes on a human's token. It is derived from the persona and its grants at request time, so revoking a role takes effect immediately instead of when the token expires.

{
  "sub": "<person_id>",  "email": "...",  "display_name": "...",
  "org_id": "...",  "app_id": "leadflow",  "tenant_id": "...",  "bu_id": "...",
  "parent_tenant_id": null,  "root_tenant_id": "...",  "reseller_id": null,
  "primary_persona_id": "...",  "all_persona_ids": ["..."],
  "admin_pool_index": "...",  "app_pool_index": { "leadflow": "pool-3" },
  "actor": { "kind": "human" },  "amr": ["pwd"]
  // "scopes" is present ONLY on machine tokens. An empty array on a human token
  // would read as "no authority" rather than "not scope-based".
}

Level 3 — The tenant registers an application

An application is the unit a machine credential belongs to. Create it with a human JWT — these routes reject an API key on purpose.

Why management routes refuse keys. A key that can mint another key cannot be contained: revoking the leaked one does not help if it already issued three more with wider scopes. Credential management is an act of administration and stays with a signed-in person. The gate lets a key reach every other tenant route — this exception is the point.
StepEndpointAuth
Create the applicationPOST /api/applicationshuman JWT
Mint a keyPOST /api/applications/{application_id}/keyshuman JWT
RotatePOST /api/api-keys/{key_id}/rotatehuman JWT
RevokePOST /api/api-keys/{key_id}/revokehuman JWT
Disable the whole appPOST /api/applications/{application_id}/disablehuman JWT
Exchange for a short-lived tokenPOST /api/auth/tokenpublic · client_credentials

environment is a property of the application, not of an individual key, and the prefix is derived from it. A test application therefore cannot mint a credential that reaches production data by accident — the guarantee is structural rather than a naming convention someone has to honour.

# Machine-to-machine: the key IS the bearer.
curl https://cloud.projexlight.com/api/crm/next-actions \
  -H "Authorization: Bearer pk_live_a1b2c3..."

Level 4 — App end users sign up

These are your tenant's customers, and there may be millions per app. They never touch signup-tenant; that call provisions a tenant and would be catastrophic here.

POST /api/auth/register public

Creates the identity.person plus alias and credential. To make that person a user of a specific app, attach the app-scoped rows:

EndpointCreatesWhy it is separate
POST /api/app-identitiesidentity.app_identityOne person, many apps — unique on (person_id, app_id). The same human in two of your apps is one person_id with two app identities, not two accounts.
POST /api/app-identities/{id}/membershipstenant_membershipWhich tenant, which business unit, and the starting role_template_id.
POST /api/personaspersona.personaThe acting identity: kind + primary_role_template_id. Everything downstream keys on persona_id.
POST /api/role-assignmentsrole assignmentGrants beyond the starting template. Body is {persona_id, role_template_id}. /api/personas/{persona_id}/roles is GET-only — it lists what a persona already holds.
POST /api/personas/{persona_id}/shredCrypto-shred one persona without erasing the person, who may still be a user of your other apps.

Why the person / persona split matters at scale

The row an end user's data hangs off is the persona, not the person. That is what lets one human be a customer in leadflow-crm and a contractor in leadflow-field with different roles, different business units, and independent deletion — while still being recognised as one person for MDM and consent. It also means an app-level deletion request shreds a persona rather than tearing a hole through every other app that person uses.


One person, many roles — the model that makes the rest work

identity.person has seven columns and not one of them says what the person is:

person_id · home_region · person_key_ref · status · mdm_method · created_at · erased_at

There is no kind, no type, no is_provider. That is deliberate and it is what makes every scenario below possible. "Tenant admin", "app user" and "provider" are not properties of a human — they are relationships, and they live one layer up.

L1 · the human — created once, never duplicated identity.person UNIQUE(kind,value_hash) on alias → 1 email = 1 person L3 · which tenant — one row per tenant they belong to membership → Tenant A tenant admin of LeadFlow membership → Tenant B app user of someone else's app membership → Tenant C their OWN tenant, added later L4 · the acting identity — everything downstream keys on this persona · owner persona · agent persona · owner application.owner_persona_id ← "provider" is persona-level ownership, not a person flag
So: do you need a separate person for provider and user? No — and you could not create one if you wanted to. identity.alias declares UNIQUE (kind, value_hash), so a given email resolves to exactly one person_id across the whole platform. A user who later becomes a provider is necessarily the same person. There is no merge step, no account linking, and no identity migration — because the split never happened.

Use case A — an existing user joins another tenant's app

supported today Nothing new is created at the person layer.

POST /api/memberships                            → tenant_membership for the OTHER tenant
POST /api/app-identities                         → app_identity for that tenant's app_id
POST /api/memberships/{membership_id}/personas   → a SECOND persona, independent roles
POST /api/auth/login  { tenant_id }              → JWT minted against that membership

Their existing personas are untouched. Omitting tenant_id at login returns a person-level token carrying no tenant claims, which is how you let someone who belongs to several tenants choose.

Do not call /api/auth/register again for this person. It fails on the unique alias — and that failure is the constraint doing its job, not a bug to work around.

Use case B — an app user becomes a provider

gap The data model supports it completely. The provisioning endpoint does not.

POST /api/auth/signup-tenant throws PersonExistsError when the email already has an alias, and POST /api/tenants accepts no person_id to bind an owner. So neither call promotes an existing user, and there is no self-service path.

Compose it from an authorised caller today:

POST /api/tenants                                → the new tenant (+ its tenant.app row)
POST /api/memberships                            → bind the EXISTING person_id to it
POST /api/memberships/{membership_id}/personas   → owner persona + owner role_template
POST /api/applications                           → their application
POST /api/applications/{application_id}/keys     → pk_live_…

The right fix is to make signup-tenant reuse the existing person_id when the alias matches and the caller proves control of it (a verified session, or re-authentication) instead of refusing outright. It is a change at one call site, not a schema change. Creating a second person for the same human would be the wrong repair: it breaks MDM convergence and splits their audit trail permanently.


Use case C — one login, many providers

supported today A person has exactly one identity.credential, so one password works everywhere. Which provider's app you enter is chosen at login, not at signup.

# No tenant named → a person-level token, no tenant claims. Use it to discover.
POST /api/auth/login { email, password }

# List what this person is subscribed to  ← the switcher reads this
GET  /api/memberships
     → [{ tenant_id, tenant_display_name, app_id, app_display_name,
           bu_id, role_template_name, has_app_identity, joined_at }, …]

# Enter one provider's app → scoped token; app_identity auto-mints on first login
POST /api/auth/login { email, password, tenant_id, app_id }
Subscription is provider-controlled, not self-service. Logging in with a tenant_id returns 403 NoMembership unless a membership already exists, so nobody can self-join a tenant by guessing an id. The provider admits them with POST /api/memberships; the app_identity then mints itself on first login (FR-IDN-5). The gate is the membership, and it sits with the provider.
Design consequence worth knowing. Because the credential is global, a provider who collects the password on their own page holds a credential that also works at every other provider. If you intend providers to be mutually untrusting, host login centrally (the platform domain) and hand providers a redirect flow, so the password is only ever typed on your domain. There is no OAuth/OIDC authorize endpoint today — inbound SAML and OAuth callback exist, but ProjexCloud does not yet act as the IdP.

Getting an API key — every step

An API key belongs to an application, and an application belongs to a tenant. So the order is: be a tenant → sign in as a human → create the application → mint the key.

Step 0 — you need a human JWT, not a key

POST /api/auth/login { email, password, tenant_id }
     → { token }        # every step below sends: Authorization: Bearer <token>
Key-management routes reject an API key by design. A key that can mint another key cannot be contained — revoking the leaked one does not help once it has issued three more. Minting stays with a signed-in person.

Step 1 — create the application

POST /api/applications
{
  "name": "LeadFlow Mobile",
  "slug": "leadflow-mobile",
  "description": "iOS + Android client",
  "environment": "test"          // "live" or "test" — see the warning below
}
     → { application_id, slug, environment, status: "active" }
environment is fixed on the APPLICATION, not on the key, and the key prefix is derived from it. A test application can never mint a credential that reaches production data — the guarantee is structural, not a naming convention. You cannot flip it later; create a second application for production.

Step 2 — mint the key

POST /api/applications/{application_id}/keys
{ "name": "server-side worker", "scopes": ["crm.contact.read", "crm.deal.write"] }
     → { key_id, api_key: "pk_test_a1b2c3…", prefix, created_at }

The full secret is returned once. Store it in your secret manager at that moment — the platform keeps only a hash, so there is no "show me the key again" call by design.

Scopes are <domain>.<resource>.<action> — three dot-separated parts, and the validator will not catch a wrong shape. It accepts any non-empty string array, so "crm" or "crm:read" is stored happily and then satisfies nothing: every call 403s. The required scope is derived from the request path rather than declared per route — action is read for GET/HEAD and write otherwise, and both segments are singularised:
RequestRequired scope
GET /api/crm/contactscrm.contact.read
POST /api/sla/clockssla.clock.write
POST /api/sla/policies/{id}/rungssla.policy.write
GET /api/sequencessequence.sequence.read
When a route has no segment after its domain, the domain doubles as the resource — hence sequence.sequence.read. Tail wildcards are accepted and are what you usually want for a server-side integration: crm.* covers every CRM resource and action, including ones added after the key was issued. A missing scope answers 403 naming both the required and the granted scopes, so it diagnoses itself — and it is a 403, never a 401.

Step 3 — call the API with it

curl https://cloud.projexlight.com/api/crm/next-actions \
  -H "Authorization: Bearer pk_live_a1b2c3…"

Same header as a JWT. The gateway routes on the pk_ prefix.

A valid key that 403s on every call is almost always mis-scoped — read the 403 body before concluding anything else. The gate names both the required and the granted scopes, so the answer is in the response:
{ "error": "Forbidden",
  "details": ["API key pk_test_ZZWB…MUUL is missing required scope: credit.balance.read"],
  "required_scopes": ["credit.balance.read"],
  "granted_scopes": ["crm.*", "sla.*", …] }
If the 403 names a scope, fix the scope — it is not a persona problem. A key scoped ["crm","rebac","consent"] fails every route this way while looking entirely reasonable in the portal.

A key is additionally bound to a synthetic persona (machine identity, created with the key in the same transaction) so that machine traffic is attributable in the audit trail as actor: {kind: service} rather than borrowing a human's. That persona starts with no role grants. Routes that authorize on tenant + scope — most reads — work immediately; a route that consults ReBAC or a role template will 403 without naming a scope, and that is the case that needs a grant:

POST /api/role-assignments {persona_id, role_template_id}

Step 4 — optionally exchange it for a short-lived token

POST /api/auth/token          # public, client_credentials
{ "grant_type": "client_credentials", "client_secret": "pk_test_a1b2c3…" }
     → { access_token, expires_in: 900, scope, tenant_id, application_id }

Prefer this when the credential would otherwise sit on a device or in a browser: a short-lived token limits the blast radius of a leak to its lifetime.

client_id is optional — and sending the wrong one is a hard 401 invalid_client. The client_secret alone identifies the key, so the safest call omits client_id entirely. If you do send it, it must be the application's slug or its application_id — not the tenant's app_id, which is a different row entirely and the easiest of the two to reach for. A mismatch is rejected rather than ignored, deliberately: silently accepting it would let a copy-paste error reach production and surface later as unexplained failures.

An optional scope (space-delimited, RFC 6749) may narrow the key's scopes and can never widen them; requesting one the key does not hold is invalid_scope. The gate enforces the token's scope claim, so narrowing is real, not decorative.

Step 5 — rotate and revoke

ActionCallEffect
RotatePOST /api/api-keys/{key_id}/rotateIssues a new secret for the same key record. Deploy the new value, then revoke the old.
Revoke one keyPOST /api/api-keys/{key_id}/revokeThat credential stops working; the application's other keys are untouched — which is the reason to mint one key per consumer rather than sharing one.
Disable everythingPOST /api/applications/{application_id}/disableKills every key the application holds at once.
ListGET /api/applications/{application_id}/keysMetadata and prefixes only — never the secrets.
Mint one key per consumer. One key for the web backend, one for the mobile BFF, one per CI pipeline. Revocation is then a decision about one consumer instead of an outage for all of them.

Roles and policy

Role templates — the reusable shape

tenant.role_template is keyed on (tenant_id, app_id, name) with parent_role_template_id for inheritance and a JSONB permissions map.

The important detail is that tenant_id is nullable:

Three layers of authorisation, and when each applies

LayerSDKAnswersReach for it when
RBACrole templates"What may this role do?"Permissions are the same for everyone holding the role.
ReBACsdk-rebac"May this persona act on that record?"Authority comes from a relationship — owner, delegate, carer, account team. Contextual roles carry a trust state and evidence.
ABACsdk-policy"Do the attributes permit it right now?"The decision depends on region, consent, time, or record state rather than on identity.

They compose in that order, and a human token deliberately carries no scopes so that a revoked grant takes effect on the next request rather than at token expiry.


Multiple apps under one tenant — read this before you model it

The schema does not currently support one tenant owning many apps in the way you may expect. tenant.tenant.app_id is TEXT NOT NULL REFERENCES tenant.app(app_id) — a tenant row belongs to exactly one app. Meanwhile the same table carries app_pool_index JSONB, a map of app → pool, which assumes a tenant spans several. Those two facts contradict each other, and the JWT follows the single-app_id side.

Three ways to model "LeadFlow owns five apps" with what exists today:

OptionHowCost
A. Tenant hierarchy no migration One root tenant for LeadFlow, one child tenant per app via parent_tenant_id / root_tenant_id. Billing and org roll up to the root. Works now and the columns already exist. Cross-app queries must walk root_tenant_id, and a person needs a membership per child tenant.
B. Join table migration Add tenant.tenant_app (tenant_id, app_id), make tenant.app_id nullable, and treat it as the tenant's primary app for back-compat. The honest fix, and it makes app_pool_index coherent. Touches every query that assumes one app per tenant — do it deliberately, not incidentally.
C. One tenant per app no migration Five flat tenants, related only in your own billing layer. Simplest, but "LeadFlow" stops existing as a platform concept and nothing rolls up.

Recommendation: start with A — it needs no migration, the hierarchy columns are already indexed, and root_tenant_id is already carried in the JWT so app-to-app authorisation can be answered from the token. Move to B when cross-app queries become common enough that walking the hierarchy is the bottleneck.

Scale note

Millions of end users per app is a routing question, not an identity one. app_pool_index maps an app to its database pool and admin_pool_index keeps tenant administration on a separate pool from end-user traffic, so a busy app cannot starve the console. Isolation tier (S shared / P private / G dedicated) is per tenant, so a large customer can be moved without touching the others. The audit ledger is chained per pool — each pool's sequence restarts at 1, so verify a chain within one pool rather than across them.


Building an operator console over your own customers

Once you serve several customers you need a screen that reads across all of them — subscriptions, usage, support. That actor is the one that must cross tenant boundaries, so it is also the single path every leak can run through. Two rules decide whether it holds.

1 · The operator is a persona in your ROOT tenant, not a role inside a customer's

The tempting shortcut is an operator value in your own app's role column. That turns a cross-tenant authority into a tenant-scoped one, and then "which customers may this person see" has no answer the schema can give. Use the hierarchy that already exists:

tenant.tenant   parent_tenant_id, root_tenant_id (materialised), reseller_id
tenant.org      org_id, parent_org_id
tenant.reseller reseller_id, org_id

Your company is the root tenant; each customer is a child tenant carrying parent_tenant_id. root_tenant_id is materialised on write so ancestor queries stay O(1), and it is already in the JWT — so "is this operator allowed to act on that customer" is answerable from the token without a join. This is the same Option A recommended above for modelling several apps; one hierarchy serves both.

The /console portal is not this. That is for ProjexCloud operators, and a tenant identity cannot sign into it. Your operator console is your own surface, built on your own root tenant.

2 · Reach the data through a different connection, not a wider flag

A session flag such as SET app.bypass_rls = true is the obvious alternative and it is the weaker one: a bug on an ordinary request can set a flag, whereas a bug cannot reach a different connection pool. The platform is already multi-pool — registerPool(pool_index, cfg) in db-runtime, with admin_pool_index and app_pool_index carried in the JWT — so this is configuration, not new machinery. Mount operator routes on their own router with their own guard; a tenant route then has no code path into an operator query rather than merely being trusted not to take one.

3 · Record the operator, not a service constant

Every cross-tenant action must name the human who took it. Writing a constant actor and a caller-supplied "on behalf of" string produces an audit trail that cannot answer the only question anyone will ask it. Where a call accepts a claimed identity, store it alongside the authenticated principal rather than instead of it, so a disagreement is visible.

The scoping contract your app should assume

Tenant comes from the credential, never from a parameter. Every tenant-facing read derives its tenant from the caller's token or API key. Endpoints do not take a tenant_id query parameter for scoping, and where one appears in a body it is checked against the credential and answers 403 on mismatch. A resource belonging to another tenant answers 404, not 403 — a 403 would confirm the id is real.

Attribution is not isolation, and the difference is easy to miss. Some records name the tenant that decided something without that decision being tenant-local. An identity merge is the clearest case: it acts on the global L1 identity.person, so a merge one tenant decides is visible to every tenant sharing that person. The tenant column tells you who acted; it does not promise the effect stopped there. When you build on a person-centric SDK, check which of the two a column means before you present it as a boundary.


End-to-end: a new LeadFlow customer

# 1 · LeadFlow onboards (once)
POST /api/auth/signup-tenant   → person + org + app + tenant + membership + app_identity
POST /api/auth/verify-email
POST /api/auth/login           → tenant-admin JWT

# 2 · LeadFlow registers an app and a machine credential (human JWT)
POST /api/applications                          → application_id
POST /api/applications/{application_id}/keys    → pk_live_...

# 3 · LeadFlow defines the roles its customers can hold
POST /api/role-templates                       → "Agent", "Manager"

# 4 · A LeadFlow customer signs up in the app (public)
POST /api/auth/register                         → person_id
POST /api/app-identities                        → app_identity (person × app)
POST /api/app-identities/{id}/memberships       → tenant + bu + role_template
POST /api/personas                              → persona_id  ← everything keys on this
POST /api/auth/login                            → end-user JWT

# 5 · Finer-grained authority as the relationship demands it
POST /api/rebac/...                             → "this agent owns that account"
POST /api/policy/...                            → "...only in-region, only with consent"

Reference