Skip to main content
ModuleX has one identity provider — Clerk — and the backend only verifies tokens; it never issues them. There are no first-party login, register, logout, or refresh endpoints. Two credential types reach the API, and a separate header carries organization scope. This page is the model: what each credential is, how it is verified, the exact header and handshake forms, and the edge cases. For the request-by-request header reference and per-endpoint error envelopes, see API authentication.

The model in one paragraph

A caller proves identity with one of two credentials, and selects an organization with a second, orthogonal value:
  • Clerk JWT — the bearer token humans carry through the web app and the realtime server. Verified against Clerk’s JWKS.
  • API key (mx_live_*) — a long-lived secret that programmatic callers and the SDKs send. Verified by a one-way fingerprint lookup using a server-side secret.
The backend tells the two apart by token prefix: a bearer value starting with mx_live_ is an API key, anything else is a Clerk JWT. Organization scope rides separately — as the X-Organization-ID HTTP header on REST and SDK calls, and as an organizationId field in the Socket.io handshake on the realtime plane. Identity and org scope are independent axes; you almost always need both.
ModuleX does not run a password database. Sign-in, sessions, and JWT minting are delegated entirely to Clerk. The backend verifies the JWT and, on a user’s first authenticated request, provisions the account just in time. See Org context & X-Organization-ID for the org axis and Roles & permissions for what each role may do.

The two credential types

Clerk JWT

Carried by the web app and the realtime server for human users. Sent as Authorization: Bearer <jwt>. Verified against Clerk’s JWKS. On first use the user is provisioned just in time.

API key (mx_live_*)

Carried by the SDKs and any programmatic caller. Sent as Authorization: Bearer mx_live_… (or X-API-KEY: mx_live_…). Verified by a one-way fingerprint lookup. Rate-limited per key and per user.
The authentication header is Authorization: Bearer, not X-Authorization. Both SDKs send Authorization: Bearer mx_live_… plus X-Organization-ID; a search for X-Authorization across the SDKs and the backend returns zero hits. There is no header divergence between the JavaScript and Python SDKs. The backend additionally accepts X-API-KEY: mx_live_… as an alternative, but neither SDK uses it.

How the backend resolves a credential

Every authenticated route runs the same resolver, which inspects headers in a fixed order and dispatches by token prefix.
1

Authorization: Bearer is checked first

If a bearer token is present and starts with mx_live_, it is treated as an API key. Otherwise it is treated as a Clerk JWT.
2

X-API-KEY is the fallback

Only consulted when no bearer credential resolved, and only when its value starts with mx_live_.
3

Neither present is a 401

With no usable credential the request fails with 401 and the message Authentication required. Provide Authorization: Bearer token or X-API-KEY header.
Resolution order

Clerk JWT path

Clerk JWTs are verified, not minted, by ModuleX. The verifier returns the token’s claims on success and rejects otherwise.

Verification

1

Configuration is required

The verifier needs both CLERK_JWKS_URL and CLERK_ISSUER. If either is unset, verification returns no claims and the request is rejected.
2

The signing key is fetched from JWKS

The JSON Web Key Set is fetched from Clerk and cached in process with a 300-second (5-minute) TTL. The token’s kid header selects the matching key; no match means rejection.
3

The RS signature and issuer are checked

The RS signature is verified against the JWKS key, then the token is decoded and its issuer is checked.
4

Claims are read

On success the verifier returns the claims; downstream code reads sub (Clerk user id), email, azp, and sid.
The JWT claims the backend reads:
string
The Clerk user id, for example user_2abc123xyz. Missing → 401 with User ID missing in token.
string
The user’s email. Missing → 401 with Email not found in token claims.
string
Authorized party (the Clerk app id).
string
Clerk session id. Read for context.
string
When the configured auth provider is clerk, the effective role is taken from this claim (upper-cased) rather than the stored database role.

Just-in-time provisioning

On the first valid JWT for an unknown user, the backend creates the account in a single transaction: a User (with the user-level role USER), a personal default organization, owner membership of that org, and seeded defaults (a starter workflow, managed-auth credentials, and a default Composer model). The operation is idempotent and concurrency-safe, and pending invitations are linked afterward.
There is no just-in-time path for API keys. A key cannot exist before its owner does: key creation (POST /api-keys) requires an authenticated, already-provisioned user, and accepts either a Clerk JWT or an existing mx_live_* API key — so an existing key can mint more keys. Because only a JWT provisions a user just in time, your first key necessarily needs a JWT. You bootstrap programmatic access by signing in to the app once (which provisions the user), then minting a key.

API key path (mx_live_*)

API keys are the programmatic credential. They are minted from an authenticated request (a Clerk JWT or an existing API key), returned in full exactly once, and stored only as a salted hash.

Format and crypto

string
Every key begins with mx_live_.
string
Base62 over 32 random bytes (256 bits), roughly 43 characters, so a full key looks like mx_live_ followed by ~43 Base62 characters.
hash
Only a one-way fingerprint of the key is persisted, hardened with a server-side secret. The plaintext key is never stored.
constant-time
Lookups use a constant-time comparison plus a prefix check, so verification time does not leak which keys exist.
string
A short hint (the first 8 characters of the random part) is stored for display. The masked form is mx_live_{hint}********.
Keys are kept as one-way fingerprints, not stored values. A server-side secret, API_KEY_PEPPER (minimum 32 characters), is required in production and staging — the app exits at startup if it is missing or too short. Because that secret lives outside the database and is folded into each fingerprint, the database alone cannot be turned back into plaintext keys. See Data security & encryption for how credential secrets are protected.

What a key carries

integer
default:"60"
Per-key throttle. Default 60 requests/minute; settable 1–1000 at creation.
string | null
default:"null"
Optional org scope. When set, the key may only act in that organization. When null, it works across all of the owner’s organizations (you still pass X-Organization-ID per request).
datetime | null
default:"null"
Optional expiry (ISO 8601). null means the key never expires.
datetime
Updated on each successful authentication, alongside last_used_ip.
System limits: 300 requests/minute per user across all keys, and a maximum of 10 keys per user.

What happens on each API-key request

1

Hash and look up

The presented key is converted to its one-way fingerprint and looked up among active keys. No match → 401 Invalid API key (and the failure is recorded for security monitoring).
2

Check expiry

An expired key resolves to no user → 401.
3

Enforce rate limits

Per-key and per-user buckets are checked. Either exceeded → 429 with X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, and Retry-After headers.
4

Load the user and stamp usage

The owning user is loaded (inactive/missing → rejected), then last_used_at and last_used_ip are updated.
5

Attach org scope if pinned

If the key is org-scoped, that scope is carried so the X-Organization-ID header can be validated against it on org-bound routes.
An org-scoped key must be used with the matching X-Organization-ID. If an org-scoped key sends a header for a different organization, the request fails with 403 API key is scoped to a different organization. An unscoped key (organization_id: null) works with any organization the owner belongs to.

Authenticated calls, three ways

The same authenticated call — fetch the current user, then make an org-scoped call — shown with cURL, Python, and JavaScript. Auth is always Authorization: Bearer mx_live_… plus X-Organization-ID on org-bound routes.
Environment-variable pickup differs between SDKs. The Python SDK falls back to MODULEX_API_KEY, MODULEX_ORGANIZATION_ID, and MODULEX_BASE_URL when arguments are omitted. The JavaScript SDK has no environment fallback — you must pass apiKey and organizationId explicitly. See JavaScript SDK and Python SDK.

API keys are tied to your account

You manage keys from the app’s API key settings, where each key is shown in full only at the moment you create it.

Realtime: the Socket.io handshake

The realtime collaboration server does not use HTTP auth headers. It authenticates once, at the Socket.io handshake, reading both the token and the organization id from the auth payload. Both fields are required, and either missing or failing rejects the entire connection.
Socket.io handshake
The realtime plane accepts Clerk JWTs only, not mx_live_* API keys. Org scope here travels in the handshake auth.organizationId field, not as the X-Organization-ID header used on REST and SDK calls.
The handshake runs the same identity-and-membership checks as REST, in order. Each failure surfaces to the client as a Socket.io connect_error (a handshake rejection), not as an error event payload:
string
required
The Clerk JWT. Missing → connect_error with Authentication required. Failing verification (bad signature, wrong issuer) → connect_error with Invalid token.
string
required
The organization UUID. Missing → connect_error with Organization ID required. Not a member of that org → connect_error with Not a member of this organization.
On success the socket joins the user:{userId} and org:{organizationId} rooms. See Socket.io collaboration events and Realtime overview for what flows over the connection.
Realtime role and membership changes can lag up to 5 minutes. The realtime server caches org membership in a server-side store with a 300-second TTL and has no live invalidation wired in, so a role or membership change made elsewhere may not reach an already-open socket until the cache expires. Treat the realtime write-gate as eventually consistent; the REST backend enforces the current role on every call.

Roles are a third, separate concern

Identity (who you are) and org scope (which org you act in) are distinct from role (what you may do in that org). After membership is confirmed, the live roles are owner and admin.
The member role was retired (2026-06-20). Document and design around owner and admin only. The retired value may still appear on legacy rows and in the realtime read-gate, but the REST edge rejects it everywhere. Agentic surfaces — Composer, the Assistant, chats, and schedules — require owner or admin, despite older in-code comments suggesting any member may use them. See Roles & permissions and Organizations, roles & membership.

Platform and admin keys (not your keys)

For completeness: ModuleX operations staff use credentials that are not mx_live_* user keys and that you never handle.
  • A shared admin dashboard key gates the internal platform dashboard. Presented as X-Admin-API-Key or Authorization: Bearer, compared constant-time against a rotation list, and it fails closed503 when unconfigured, 401 when missing, 403 on mismatch.
  • A separate super-admin gate requires a user-level SUPER_ADMIN role and a @modulex.dev email.
  • The realtime server has its own admin gate (a configured admin key plus a Clerk token and a single allowed email).
The legacy MODULEX_API_KEY scheme has been removed. Never conflate these platform credentials with your mx_live_* keys.

Edge cases and gotchas

Authentication is fully delegated to Clerk. The backend only verifies Clerk JWTs and provisions the user just in time. Any client code that posts to /auth/login is vestigial and hits no route.
The backend accepts X-API-KEY: mx_live_… as an alternative to Authorization: Bearer. Both SDKs use Authorization: Bearer exclusively. A self-hosted or hand-rolled caller may use X-API-KEY directly if preferred.
Some request bodies carry their own organizationId field that sets the resource owner (for example, which org a new API key belongs to). That is independent of the X-Organization-ID header, which selects the org you are acting in. Do not conflate the two.
Request headers are PascalCase-hyphen: X-Organization-ID, X-API-KEY, X-Admin-API-Key, and the X-RateLimit-* family. Response bodies are snake_case (organization_ids, primary_organization_id, current_organization_id). HTTP header lookup is case-insensitive, but quote these exact casings.
POST /api-keys is the only response that returns the plaintext key. Every later read returns the masked form mx_live_{hint}********. Store the key securely at creation; it cannot be recovered.
Use the Clerk-backed web app for interactive work; the browser carries the JWT for you. Use an mx_live_* API key for servers, scripts, CI, and the SDKs. To get your first key, sign in once (which provisions your account), then mint a key from API key settings.

API authentication

The request-level header reference, key format, and per-endpoint auth error responses.

Org context & X-Organization-ID

How the org header scopes every org-bound request, and how scope is resolved.

Roles & permissions

The owner/admin role model and which actions each role may perform.

Data security & encryption

How API keys and credential secrets are protected.