> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modulex.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication model

> How ModuleX authenticates callers: Clerk JWTs for the web app and mx_live_ API keys (kept as one-way fingerprints) for programmatic access, plus the Authorization: Bearer header forms and the Socket.io handshake auth payload.

export const MediaEmbed = ({id, type = 'screenshot', caption = '', ext, ratio = '16 / 9'}) => {
  const isVideo = type === 'video' || type === 'app_video';
  const resolvedExt = ext || (isVideo ? 'mp4' : type === 'screenshot' ? 'webp' : 'svg');
  const src = 'https://media.modulex.dev/' + id + '.' + resolvedExt;
  const [status, setStatus] = useState('loading');
  const [isDev, setIsDev] = useState(false);
  const [inView, setInView] = useState(false);
  const boxRef = useRef(null);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const h = window.location.hostname;
    setIsDev(h === 'localhost' || h === '127.0.0.1' || h.endsWith('.mintlify.app'));
  }, []);
  useEffect(() => {
    if (inView) return;
    if (typeof IntersectionObserver === 'undefined') {
      setInView(true);
      return;
    }
    const el = boxRef.current;
    if (!el) return;
    const io = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) {
        setInView(true);
        io.disconnect();
      }
    }, {
      rootMargin: '300px'
    });
    io.observe(el);
    return () => io.disconnect();
  }, [inView]);
  if (status === 'missing') {
    if (!isDev) return null;
    return <div style={{
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      gap: '0.4rem',
      padding: '1rem 1.25rem',
      margin: '1.25rem 0',
      width: '100%',
      aspectRatio: ratio,
      boxSizing: 'border-box',
      border: '1px dashed rgba(128,128,128,0.45)',
      borderRadius: '0.75rem',
      background: 'rgba(128,128,128,0.06)',
      color: 'currentColor',
      fontSize: '0.85rem',
      lineHeight: 1.45
    }}>
        <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      opacity: 0.75
    }}>
          <span aria-hidden="true">🎬</span>
          <code style={{
      fontSize: '0.75rem'
    }}>{id}</code>
          <span style={{
      fontSize: '0.65rem',
      textTransform: 'uppercase',
      letterSpacing: '0.04em',
      padding: '0.1rem 0.4rem',
      borderRadius: '0.4rem',
      background: 'rgba(128,128,128,0.18)'
    }}>
            {type}
          </span>
        </div>
        <div style={{
      opacity: 0.9
    }}>{caption || 'Media not uploaded yet.'}</div>
        <div style={{
      fontSize: '0.7rem',
      opacity: 0.5
    }}>
          Upload to R2 as <code>{id}.{resolvedExt}</code> — preview only, hidden in production.
        </div>
      </div>;
  }
  const mediaStyle = {
    display: status === 'loaded' ? 'block' : 'none',
    width: '100%',
    height: 'auto',
    borderRadius: '0.75rem'
  };
  const media = isVideo ? <video src={inView ? src : undefined} autoPlay loop muted playsInline preload="metadata" onLoadedData={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} /> : <img src={inView ? src : undefined} alt={caption} onLoad={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} />;
  return <figure style={{
    margin: '1.25rem 0'
  }}>
      <div ref={boxRef} style={status === 'loaded' ? {
    width: '100%'
  } : {
    width: '100%',
    aspectRatio: ratio,
    borderRadius: '0.75rem',
    background: 'rgba(128,128,128,0.06)'
  }}>
        {media}
      </div>
      {status === 'loaded' && caption ? <figcaption style={{
    marginTop: '0.5rem',
    textAlign: 'center',
    fontSize: '0.85rem',
    opacity: 0.7
  }}>
          {caption}
        </figcaption> : null}
    </figure>;
};

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](/api-reference/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.

<Note>
  **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](/security/org-context) for the org axis and [Roles & permissions](/security/roles-permissions) for what each role may do.
</Note>

## The two credential types

<CardGroup cols={2}>
  <Card title="Clerk JWT" icon="id-card">
    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.
  </Card>

  <Card title="API key (mx_live_*)" icon="key">
    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.
  </Card>
</CardGroup>

| Property                         | Clerk JWT                                                       | API key (`mx_live_*`)                                                                                |
| -------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Who sends it                     | Web app, realtime server (humans)                               | SDKs, scripts, servers (machines)                                                                    |
| Issued by                        | Clerk                                                           | ModuleX (`POST /api-keys`)                                                                           |
| Lifetime                         | Short-lived session token (Clerk-managed)                       | Long-lived; optional `expires_at`, else never                                                        |
| Header form                      | `Authorization: Bearer <jwt>`                                   | `Authorization: Bearer mx_live_…` or `X-API-KEY: mx_live_…`                                          |
| Verified by                      | Clerk JWKS (RS signature + issuer)                              | One-way fingerprint lookup, constant-time compare                                                    |
| Org scope                        | `X-Organization-ID` header (or Socket.io `auth.organizationId`) | `X-Organization-ID` header; key may be pinned to one org                                             |
| `auth_method` on the request     | `clerk` (or `jwt`)                                              | `api_key`                                                                                            |
| Created without an existing user | Yes — just-in-time provisioning                                 | No — key creation requires an authenticated, already-provisioned user (a JWT or an existing API key) |

<Warning>
  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.
</Warning>

## 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.

<Steps>
  <Step title="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**.
  </Step>

  <Step title="X-API-KEY is the fallback">
    Only consulted when no bearer credential resolved, and only when its value starts with `mx_live_`.
  </Step>

  <Step title="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.`
  </Step>
</Steps>

```text Resolution order theme={null}
1. Authorization: Bearer mx_live_…   → API key path
2. Authorization: Bearer <jwt>       → Clerk JWT path
3. X-API-KEY: mx_live_…              → API key path (fallback only)
4. (none)                            → 401
```

## Clerk JWT path

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

### Verification

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Claims are read">
    On success the verifier returns the claims; downstream code reads `sub` (Clerk user id), `email`, `azp`, and `sid`.
  </Step>
</Steps>

The JWT claims the backend reads:

<ResponseField name="sub" type="string">
  The Clerk user id, for example `user_2abc123xyz`. Missing → `401` with `User ID missing in token`.
</ResponseField>

<ResponseField name="email" type="string">
  The user's email. Missing → `401` with `Email not found in token claims`.
</ResponseField>

<ResponseField name="azp" type="string">
  Authorized party (the Clerk app id).
</ResponseField>

<ResponseField name="sid" type="string">
  Clerk session id. Read for context.
</ResponseField>

<ResponseField name="metadata.role" type="string">
  When the configured auth provider is `clerk`, the effective role is taken from this claim (upper-cased) rather than the stored database role.
</ResponseField>

### 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.

<Note>
  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.
</Note>

## 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

<ParamField path="prefix" type="string">
  Every key begins with `mx_live_`.
</ParamField>

<ParamField path="random part" type="string">
  Base62 over **32 random bytes (256 bits)**, roughly **43 characters**, so a full key looks like `mx_live_` followed by \~43 Base62 characters.
</ParamField>

<ParamField path="storage" type="hash">
  Only a one-way fingerprint of the key is persisted, hardened with a server-side secret. The plaintext key is **never** stored.
</ParamField>

<ParamField path="verification" type="constant-time">
  Lookups use a constant-time comparison plus a prefix check, so verification time does not leak which keys exist.
</ParamField>

<ParamField path="hint and mask" type="string">
  A short hint (the first 8 characters of the random part) is stored for display. The masked form is `mx_live_{hint}********`.
</ParamField>

<Note>
  **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](/security/data-encryption) for how credential secrets are protected.
</Note>

### What a key carries

<ResponseField name="rate_limit_per_minute" type="integer" default="60">
  Per-key throttle. Default 60 requests/minute; settable 1–1000 at creation.
</ResponseField>

<ResponseField name="organization_id" type="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).
</ResponseField>

<ResponseField name="expires_at" type="datetime | null" default="null">
  Optional expiry (ISO 8601). `null` means the key never expires.
</ResponseField>

<ResponseField name="last_used_at" type="datetime">
  Updated on each successful authentication, alongside `last_used_ip`.
</ResponseField>

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

<Steps>
  <Step title="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).
  </Step>

  <Step title="Check expiry">
    An expired key resolves to no user → `401`.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Load the user and stamp usage">
    The owning user is loaded (inactive/missing → rejected), then `last_used_at` and `last_used_ip` are updated.
  </Step>

  <Step title="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.
  </Step>
</Steps>

<Warning>
  **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.
</Warning>

### 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.

<CodeGroup>
  ```bash cURL theme={null}
  # Identity only (no org needed):
  curl https://api.modulex.dev/auth/me \
    -H "Authorization: Bearer mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u"

  # Org-scoped call (org-bound route needs the header):
  curl https://api.modulex.dev/organizations/llms \
    -H "Authorization: Bearer mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u" \
    -H "X-Organization-ID: a1b2c3d4-0000-4000-8000-000000000000"
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import Modulex

  async def main():
      async with Modulex(
          api_key="mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u",
          organization_id="a1b2c3d4-0000-4000-8000-000000000000",
      ) as client:
          me = await client.auth.me()              # GET /auth/me
          llms = await client.organizations.llms() # uses X-Organization-ID
          print(me, llms)

  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  import { Modulex } from "modulex-js";

  const client = new Modulex({
    apiKey: "mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u",
    organizationId: "a1b2c3d4-0000-4000-8000-000000000000",
  });

  const me = await client.auth.me();              // GET /auth/me
  const { active_llms } = await client.organizations.llms(); // uses X-Organization-ID
  ```
</CodeGroup>

<Note>
  **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](/sdks/javascript) and [Python SDK](/sdks/python).
</Note>

<MediaEmbed id="MX-MEDIA-4220" type="image" caption={"Authentication model diagram showing the two identity credentials and the orthogonal org-scope axis."} />

## 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.

<MediaEmbed id="MX-MEDIA-4221" type="screenshot" caption={"The API keys settings page showing a key list with masked keys and the create-key dialog."} />

## 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.

```javascript Socket.io handshake theme={null}
const socket = io("wss://realtime.modulex.dev", {
  auth: {
    token: "<clerk_jwt>",                                  // required
    organizationId: "a1b2c3d4-0000-4000-8000-000000000000" // required
  }
});
```

<Note>
  **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.
</Note>

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:

<ParamField path="auth.token" type="string" required>
  The Clerk JWT. Missing → `connect_error` with `Authentication required`. Failing verification (bad signature, wrong issuer) → `connect_error` with `Invalid token`.
</ParamField>

<ParamField path="auth.organizationId" type="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`.
</ParamField>

| Failure at handshake            | `connect_error` message             |
| ------------------------------- | ----------------------------------- |
| `token` missing                 | `Authentication required`           |
| `organizationId` missing        | `Organization ID required`          |
| Token fails verification        | `Invalid token`                     |
| User is not a member of the org | `Not a member of this organization` |

On success the socket joins the `user:{userId}` and `org:{organizationId}` rooms. See [Socket.io collaboration events](/realtime/socket-events) and [Realtime overview](/realtime/overview) for what flows over the connection.

<Warning>
  **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.
</Warning>

## 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`**.

<Warning>
  **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](/security/roles-permissions) and [Organizations, roles & membership](/concepts/organizations-roles).
</Warning>

## 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 closed** — `503` 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

<AccordionGroup>
  <Accordion title="There is no /auth/login, /register, /logout, or /refresh">
    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.
  </Accordion>

  <Accordion title="X-API-KEY works against the backend but no SDK sends it">
    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.
  </Accordion>

  <Accordion title="A request body's organizationId is not the X-Organization-ID header">
    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.
  </Accordion>

  <Accordion title="Header casing is exact on the wire">
    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.
  </Accordion>

  <Accordion title="The full API key is shown exactly once">
    `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.
  </Accordion>

  <Accordion title="Web app vs API key — which to use">
    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.
  </Accordion>
</AccordionGroup>

## Related

<CardGroup cols={2}>
  <Card title="API authentication" icon="lock" href="/api-reference/authentication">
    The request-level header reference, key format, and per-endpoint auth error responses.
  </Card>

  <Card title="Org context & X-Organization-ID" icon="building" href="/security/org-context">
    How the org header scopes every org-bound request, and how scope is resolved.
  </Card>

  <Card title="Roles & permissions" icon="user-shield" href="/security/roles-permissions">
    The owner/admin role model and which actions each role may perform.
  </Card>

  <Card title="Data security & encryption" icon="shield-check" href="/security/data-encryption">
    How API keys and credential secrets are protected.
  </Card>
</CardGroup>
