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

# Integration authentication & credentials

> How ModuleX integrations authenticate: the six auth schema variants, the OAuth2 PKCE connect flow, encryption at rest, and how credentials are resolved at run time.

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>;
};

Every integration in ModuleX needs a way to prove who it is to the service it
calls. An integration declares the auth methods it supports in its manifest; you
supply the secret once, ModuleX encrypts it, and the runtime resolves and decrypts
it on every tool call. This page is the exhaustive reference for that contract: the
six auth schema variants a manifest can expose, the OAuth2 authorization-code flow
with PKCE, how secrets are encrypted at rest, and the precedence rules that pick a
credential at run time.

For the conceptual model and a guided OAuth walkthrough, see
[Credentials & OAuth2](/concepts/credentials-oauth). To create, set defaults for,
test, and delete credentials in the app and over the API, see
[Managing credentials](/integrations/managing-credentials).

<Note>
  A credential is **organization-scoped**, never user-scoped. Every credential
  belongs to one organization and is encrypted, resolved, and billed against that
  org. All credential endpoints require the **owner** or **admin** role
  (`organization_admin_required`); the `member` role is retired. The org is selected
  by the `X-Organization-ID` header on every request — see
  [Org context & X-Organization-ID](/security/org-context).
</Note>

## The six auth schema variants

An integration manifest exposes one or more **auth schemas** under
`auth_schemas`. The list is a discriminated union keyed on `auth_type`, so each
entry is exactly one of six variant classes. A single integration may ship several
(GitHub ships both OAuth2 and a bearer-token personal-access-token option; Exa ships
both an API key and a ModuleX-managed key).

| `auth_type`    | Variant class           | What you supply                                             | Typical use                                    |
| -------------- | ----------------------- | ----------------------------------------------------------- | ---------------------------------------------- |
| `oauth2`       | `OAuth2AuthSchema`      | Nothing directly — you complete a consent flow              | GitHub, Google Calendar, Netlify               |
| `bearer_token` | `BearerTokenAuthSchema` | A long-lived token (e.g. a personal access token)           | GitHub PAT                                     |
| `api_key`      | `ApiKeyAuthSchema`      | A provider-issued API key                                   | Exa, ConvertAPI, Hunter, Freshdesk             |
| `modulex_key`  | `ModulexKeyAuthSchema`  | Nothing — ModuleX supplies a managed key, billed in credits | Exa (managed), Firecrawl, Jina AI, Hacker News |
| `custom`       | `CustomAuthSchema`      | Free-form fields defined by the integration                 | PostgreSQL, WooCommerce, Coinbase              |
| `internal`     | `InternalAuthSchema`    | Reserved — not supplied by you                              | System-managed only                            |

<Warning>
  `internal` is a **reserved/forward** variant. It is a valid `auth_type` and a
  defined schema class, but no shipped integration in the catalog uses it, and
  `internal` credentials are excluded from credential listings (they back
  system-managed resources such as the managed knowledge store). Treat it as
  read-only and do not author manifests against it.
</Warning>

<Note>
  There is one casing edge case to know about. The integration manifest schema
  defines **six** `auth_type` values. The backend credential table's CHECK
  constraint allows **seven** — it additionally accepts a legacy `bearer` value with
  no corresponding manifest variant. New integrations must emit only the six schema
  variants above; `bearer` is a legacy database value, not a manifest option.
</Note>

### Shared fields on every variant

Every auth schema, regardless of `auth_type`, inherits the same base fields.

<ResponseField name="display_name" type="string" required>
  Human-readable label shown in the connect UI (e.g. `"OAuth2 Authentication"`).
</ResponseField>

<ResponseField name="description" type="string" required>
  One-line explanation of the method shown next to the label.
</ResponseField>

<ResponseField name="setup_instructions" type="string[] | null" default="null">
  An ordered list of steps shown to the person connecting the integration (e.g. how
  to create a personal access token).
</ResponseField>

<ResponseField name="setup_environment_variables" type="EnvVar[]" default="[]">
  Operator- or user-supplied secrets and settings for this method. See
  [`EnvVar`](#envvar-operator-or-user-supplied-values) below.
</ResponseField>

<ResponseField name="test_endpoint" type="TestEndpoint | null" default="null">
  An optional HTTP call the runtime makes to validate a configured credential. Some
  `modulex_key` public-API integrations ship none, in which case credential testing
  is skipped. See [`TestEndpoint`](#testendpoint-credential-validation).
</ResponseField>

### OAuth2 variant (`oauth2`)

`OAuth2AuthSchema` adds a single extra field, `oauth_config`, that describes the
authorization-code flow.

<Expandable title="OAuthConfig fields">
  <ParamField path="auth_url" type="string" required>
    The provider's authorization endpoint the user is redirected to (e.g.
    `https://github.com/login/oauth/authorize`).
  </ParamField>

  <ParamField path="token_url" type="string" required>
    The provider's token-exchange endpoint where the authorization code is swapped for
    tokens (e.g. `https://github.com/login/oauth/access_token`).
  </ParamField>

  <ParamField path="scopes" type="string[]" default="[]">
    OAuth scopes requested at consent (e.g. `["repo", "user", "read:org", "workflow"]`).
    A connect request may override these with its own `scope` string.
  </ParamField>

  <ParamField path="token_auth_method" type="&#x22;body&#x22; | &#x22;basic&#x22;" default="&#x22;body&#x22;">
    How the client credentials are presented at token exchange. `body` sends
    `client_id`/`client_secret` in the form body (RFC 6749 `client_secret_post`);
    `basic` sends them as HTTP Basic auth (used by providers such as Notion).
  </ParamField>

  <ParamField path="access_type" type="string | null" default="null">
    Extra authorize parameter for providers that need it. Google requires
    `"offline"` to issue a `refresh_token`.
  </ParamField>

  <ParamField path="prompt" type="string | null" default="null">
    Extra authorize parameter. `"consent"` re-issues a refresh token on every
    reconnect (Google).
  </ParamField>

  <ParamField path="use_pkce" type="boolean" default="true">
    Whether to use PKCE (RFC 7636, S256). On by default. A manifest can set this to
    `false` for providers that reject an unexpected `code_verifier` (Netlify does).
  </ParamField>
</Expandable>

<Warning>
  **PKCE-opt-out (`use_pkce: false`) may not be honored end to end.** The manifest
  field exists, but the runtime is documented as currently hardcoding PKCE on, so a
  manifest that sets `use_pkce: false` may still send a `code_verifier`. This is an
  [open question](/reference/known-limitations) pending verification against the live
  runtime — do not rely on opting out of PKCE until it is confirmed wired. For
  providers that accept PKCE (the large majority), the default behavior is correct.
</Warning>

### Bearer-token variant (`bearer_token`)

`BearerTokenAuthSchema` adds no extra fields. You supply a long-lived token (e.g. a
GitHub personal access token). The runtime sends it as
`Authorization: Bearer <token>` to the service. On the wire the stored
`auth_type` is `bearer_token`.

### API-key variant (`api_key`)

`ApiKeyAuthSchema` adds no extra fields. You supply a provider-issued API key. How
the key is presented (header name, query parameter, etc.) is defined by the
integration's `test_endpoint` and its tool functions, not by a fixed convention.

### ModuleX-managed-key variant (`modulex_key`)

`ModulexKeyAuthSchema` adds no extra fields, and you supply **nothing** — ModuleX
provisions a managed key from a pooled key store. Usage of a `modulex_key`
credential is **metered in credits** and passes through the billing gate at run
time (see [Resolution at run time](#how-credentials-are-resolved-at-run-time)
below and [Credits & metering](/billing/credits)). This is the difference that
matters: a `modulex_key` credential always carries a usage/credit gate; a
credential you supply yourself never does.

### Custom variant (`custom`)

`CustomAuthSchema` adds no extra fields at the schema level. The integration
defines the fields it needs through `setup_environment_variables`, and the connect
UI renders one input per declared `EnvVar`. PostgreSQL (host, port, database, user,
password) is a typical example.

## `EnvVar` — operator- or user-supplied values

Each auth schema can declare a list of `EnvVar` entries under
`setup_environment_variables`. An `EnvVar` is a single secret or setting, and two
of its flags — `only_for_custom` and `inject_into_auth_data` — decide where the
value comes from and whether a tool function can read it.

<ResponseField name="name" type="string" required>
  Env-var-style key (e.g. `GITHUB_OAUTH2_CLIENT_ID`).
</ResponseField>

<ResponseField name="display_name" type="string" required>
  Label shown in the UI.
</ResponseField>

<ResponseField name="description" type="string" required>
  Help text shown under the field.
</ResponseField>

<ResponseField name="required" type="boolean" default="true">
  Whether the value must be supplied. Note this defaults to `true` — the opposite of
  `ParameterDef.required`, which defaults to `false`.
</ResponseField>

<ResponseField name="sensitive" type="boolean" default="false">
  Whether the value is a secret. Sensitive values are masked in the UI.
</ResponseField>

<ResponseField name="only_for_custom" type="boolean" default="false">
  `true` marks the value as a **server-level** secret: the ModuleX-managed app
  resolves it from the server environment, while a bring-your-own-app user supplies
  their own. Examples: a GitHub OAuth client id/secret, a Google Ads
  `developer_token`.
</ResponseField>

<ResponseField name="inject_into_auth_data" type="boolean" default="false">
  `true` guarantees the value is present in `auth_data` at action-execution time so a
  tool function can read it (the key is prefix-stripped and lowercased). `false`
  (the default) means the value is used only for OAuth provider config and the test
  endpoint, and never reaches a tool call.
</ResponseField>

<ResponseField name="sample_format" type="string | null" default="null">
  Placeholder hint shown in the input (e.g. `"ghp_xxxx..."`).
</ResponseField>

<ResponseField name="about_url" type="string | null" default="null">
  Link to where the user obtains the value.
</ResponseField>

The interaction of the two flags determines handling:

| `inject_into_auth_data` | `only_for_custom` | Behavior                                                                                                                   |
| ----------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `false` (default)       | any               | OAuth/test-only; never reaches a tool function.                                                                            |
| `true`                  | `false`           | Per-credential **user input**, persisted into `auth_data` at credential creation.                                          |
| `true`                  | `true`            | **Server-level secret**; the managed app injects it from server env at resolution time; a BYO-app user supplies their own. |

## `TestEndpoint` — credential validation

When an auth schema declares a `test_endpoint`, ModuleX can validate a credential
by making one HTTP call before relying on it. Placeholders in the URL, headers,
params, or body — such as `{access_token}`, `{token}`, `{api_key}`,
`{bearer_token}`, or any `auth_data`/`EnvVar` key — are substituted by the runtime.

<Expandable title="TestEndpoint fields">
  <ParamField path="url" type="string" required>
    The endpoint to call. May embed query placeholders (e.g. `?Secret={api_key}`).
  </ParamField>

  <ParamField path="method" type="&#x22;GET&#x22; | &#x22;POST&#x22; | &#x22;PUT&#x22; | &#x22;DELETE&#x22; | &#x22;PATCH&#x22;" default="&#x22;GET&#x22;">
    HTTP method for the validation call.
  </ParamField>

  <ParamField path="headers" type="object" default="{}">
    Request headers, with placeholder substitution (e.g.
    `{"Authorization": "Bearer {access_token}"}`).
  </ParamField>

  <ParamField path="params" type="object" default="{}">
    URL query parameters, for query-parameter credentials.
  </ParamField>

  <ParamField path="body" type="object | null" default="null">
    JSON payload for non-GET validation calls.
  </ParamField>

  <ParamField path="auth" type="BasicAuthSpec | null" default="null">
    Declarative HTTP Basic auth. When set, the runtime builds
    `Authorization: Basic <base64(user:pass)>` itself and ignores any `Authorization`
    header you set. A placeholder that is not an `auth_data` key is treated as a
    literal string.
  </ParamField>

  <ParamField path="success_indicators" type="SuccessIndicators" required>
    What counts as success: a list of acceptable `status_codes` (e.g. `[200]`) and an
    optional list of `response_fields` that must appear in the response body (e.g.
    `["login", "id"]`).
  </ParamField>

  <ParamField path="cost_level" type="string" default="&#x22;free&#x22;">
    Free-form cost hint. Observed values include `"free"` and `"minimal"`; the schema
    does not constrain the set.
  </ParamField>

  <ParamField path="description" type="string | null" default="null">
    Human-readable note describing what the validation checks.
  </ParamField>
</Expandable>

When you test a credential, the response reports `test_method`, which is one of
`api_call` (an actual request was made), `basic`, or `none` (the integration ships
no `test_endpoint`, so testing is skipped and the credential is treated as valid).
See [Managing credentials](/integrations/managing-credentials) for the full
test-and-save flow.

## How an integration reads the credential

A tool function receives its credential through one of two run-time conventions,
chosen by the auth type:

* **Token-based** (`oauth2`, `bearer_token`) — the function signature leads with
  `auth_type: str, auth_data: dict[str, Any]`, then its action parameters. It
  builds request headers from `(auth_type, auth_data)`.
* **Key-based** (`api_key`, `modulex_key`) — the function takes the action
  parameters plus `api_key: str` directly (often sent as `x-api-key: {api_key}`),
  and checks the key is non-empty before calling out.

This is part of the `@tool` function contract; for the full decorator order, output
models, and worked examples, see
[The @tool function contract](/integrations/building/tool-contract) and
[Manifest & schema contract](/integrations/building/manifest-schema).

## The OAuth2 connect flow (PKCE)

OAuth2 integrations are connected through an authorization-code flow with PKCE. You
never paste a token; ModuleX initiates the flow, the provider redirects the
browser back, and ModuleX exchanges the code and stores the resulting credential.

<MediaEmbed id="MX-MEDIA-4020" type="image" caption={"Sequence diagram of the OAuth2 PKCE connect flow."} />

### Step 1 — initiate

`POST /credentials/oauth2/initiate` starts the flow. The backend loads the
integration's `oauth2` schema, builds the authorization URL (generating a PKCE
`code_verifier` and S256 `code_challenge`), stores the flow state in Redis with a
**5-minute TTL**, and returns the URL for the browser to open.

<ParamField path="integration_name" type="string" required>
  The integration to connect. Must expose an `oauth2` auth schema, or the request
  fails with `400`.
</ParamField>

<ParamField path="use_modulex_oauth" type="boolean" default="true">
  `true` uses the ModuleX-registered OAuth app for the provider (the managed path).
  `false` uses your own OAuth app and requires `custom_oauth_config`.
</ParamField>

<ParamField path="custom_oauth_config" type="object">
  Required only when `use_modulex_oauth` is `false`. Must contain `client_id` and
  `client_secret` for your own OAuth app. The `auth_url`/`token_url` come from the
  integration schema.
</ParamField>

<ParamField path="redirect_uri" type="string" required>
  The callback URL the provider redirects to after consent. This is the ModuleX
  backend callback (e.g. `https://api.modulex.dev/credentials/oauth2/callback`).
</ParamField>

<ParamField path="scope" type="string">
  Space-separated scopes. Defaults to the provider/schema scopes joined by spaces.
</ParamField>

<ParamField path="display_name" type="string">
  A label for the credential that gets created.
</ParamField>

<ParamField path="make_default" type="boolean" default="false">
  Whether the new credential becomes the default for its integration.
</ParamField>

<ParamField path="env_var_values" type="object">
  Per-user `EnvVar` values (string-to-string). Only values whose `EnvVar` has
  `inject_into_auth_data: true` and `only_for_custom: false` are folded into the
  persisted `auth_data`.
</ParamField>

<ParamField path="composer_chat_id" type="string">
  Composer auto-resume linkage. Must be supplied together with
  `composer_request_id` and `composer_llm_config`; supplying some but not all returns
  `400`.
</ParamField>

<ParamField path="composer_request_id" type="string">
  Composer auto-resume linkage — see `composer_chat_id`.
</ParamField>

<ParamField path="composer_llm_config" type="object">
  Composer auto-resume linkage — see `composer_chat_id`.
</ParamField>

Authenticate with a key or a Clerk JWT plus the org header, exactly as for any
ModuleX API call (see [Authentication](/api-reference/authentication)):

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/credentials/oauth2/initiate \
    -H "Authorization: Bearer mx_live_abc123" \
    -H "X-Organization-ID: 8f2c1d9e-0000-4a11-9c3d-2b6e7f4a1234" \
    -H "Content-Type: application/json" \
    -d '{
          "integration_name": "github",
          "use_modulex_oauth": true,
          "redirect_uri": "https://api.modulex.dev/credentials/oauth2/callback",
          "display_name": "Production GitHub",
          "make_default": true
        }'
  # → {"authorization_url": "https://github.com/login/oauth/authorize?...", "state": "<token>"}
  ```

  ```python Python theme={null}
  import os
  import httpx

  resp = httpx.post(
      "https://api.modulex.dev/credentials/oauth2/initiate",
      headers={
          "Authorization": f"Bearer {os.environ['MODULEX_API_KEY']}",
          "X-Organization-ID": "8f2c1d9e-0000-4a11-9c3d-2b6e7f4a1234",
      },
      json={
          "integration_name": "github",
          "use_modulex_oauth": True,
          "redirect_uri": "https://api.modulex.dev/credentials/oauth2/callback",
          "display_name": "Production GitHub",
          "make_default": True,
      },
  )
  data = resp.json()  # {"authorization_url": "...", "state": "..."}
  ```

  ```javascript JavaScript theme={null}
  const resp = await fetch(
    "https://api.modulex.dev/credentials/oauth2/initiate",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.MODULEX_API_KEY}`,
        "X-Organization-ID": "8f2c1d9e-0000-4a11-9c3d-2b6e7f4a1234",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        integration_name: "github",
        use_modulex_oauth: true,
        redirect_uri: "https://api.modulex.dev/credentials/oauth2/callback",
        display_name: "Production GitHub",
        make_default: true,
      }),
    },
  );
  const data = await resp.json(); // { authorization_url, state }
  ```
</CodeGroup>

<ResponseField name="authorization_url" type="string">
  The provider authorize URL to open in the browser. Includes the `state` and PKCE
  `code_challenge`.
</ResponseField>

<ResponseField name="state" type="string">
  A one-time CSRF/correlation token. ModuleX stores the flow state in Redis keyed by
  this value with a 5-minute TTL.
</ResponseField>

<Note>
  Both official SDKs expose this initiate call — `credentials.initiateOAuth2(params)`
  (JavaScript) and `credentials.initiate_oauth2(integration_name, *, redirect_uri, ...)`
  (Python) — to **start** the flow and return the `authorization_url`. They cannot
  complete it for you: finishing consent requires opening that `authorization_url` in
  a browser so the provider redirect hits the backend callback. In the app, the
  connect UI performs the same request and opens the consent page for you. See
  [Managing credentials](/integrations/managing-credentials).
</Note>

### Step 2 — provider consent and callback

The browser opens `authorization_url`, the user approves the requested scopes, and
the provider redirects back to the `redirect_uri`, which is the backend callback:

`GET /credentials/oauth2/callback?code=...&state=...`

This endpoint is the only credential route that is **anonymous** — the browser
arriving from the provider carries no ModuleX session. Trust comes entirely from
the one-time Redis `state` value. The callback:

<Steps>
  <Step title="Validate state">
    Reads and deletes the Redis `state` (one-time read). If it is missing — expired
    past five minutes or already used — the flow ends in an error redirect with
    `error_code=invalid_state`.
  </Step>

  <Step title="Exchange the code">
    Posts the authorization code (and PKCE `code_verifier`) to the provider's
    `token_url`, presenting client credentials per the schema's `token_auth_method`
    (`body` or `basic`). Failure ends in `error_code=token_exchange_failed`.
  </Step>

  <Step title="Build and store the credential">
    Assembles `auth_data` (`access_token`, `token_type` defaulting to `bearer`, an
    optional `refresh_token`, and `expires_at` derived from `expires_in`), folds in any
    injected `EnvVar` values, encrypts it, and creates the credential. The credential
    is created with no `created_by` because the call is anonymous.
  </Step>

  <Step title="Redirect back to the app">
    Always returns a **302** to the frontend landing page — never JSON. On success the
    URL carries `?status=success&integration=<name>&credential_id=<uuid>`; on failure
    `?status=error&integration=<name>&error_code=<code>&message=<urlencoded>`.
  </Step>
</Steps>

The callback **always** responds with a 302 redirect, even on error, so the app can
show the outcome. Recognized `error_code` values include `oauth_denied`,
`oauth_provider_error`, `missing_params`, `invalid_state`,
`token_exchange_failed`, `credential_creation_failed`, `invalid_credentials`, and
`internal_error`.

### Token refresh — and why you reconnect instead

ModuleX refreshes OAuth2 access tokens **automatically at run time**. When a
credential is resolved for a tool call and its `expires_at` is within five minutes
of expiring, the resolver uses the stored `refresh_token` and decrypted
`oauth_config` to obtain a new access token, re-encrypts the credential, and
continues. You do not trigger this; it happens inside resolution.

<Warning>
  **Do not rely on a manual "refresh OAuth" action — it is broken.** The manual
  OAuth-refresh path in the app is a known limitation: the frontend route that the
  "refresh" control would call does not exist, so the call fails before it reaches
  the backend. If an OAuth credential ever needs to be re-established (for example,
  the provider revoked the grant, or the credential never received a `refresh_token`
  because the provider needs `access_type=offline`/`prompt=consent`), **reconnect the
  integration** — run the connect flow again from Step 1, which issues fresh tokens
  and updates the credential. See
  [Known limitations](/reference/known-limitations).
</Warning>

<Note>
  There is also a behavioral gap to be aware of in automatic refresh. The automatic
  in-resolution refresh always presents client credentials in the request body and
  ignores the schema's `token_auth_method`. A provider that requires `basic`-auth at
  the token endpoint (such as Notion) would fail an automatic refresh with
  `invalid_client`; reconnecting re-issues a working credential. This is logged as a
  parity gap.
</Note>

## How credentials are stored (encrypted at rest)

Credentials are never stored in plaintext. Each credential's secret material is
encrypted before it is written and decrypted only in memory at the moment of use.

* **Per-credential isolation.** Each credential is sealed with its own key, bound to
  your organization and that specific credential, so a stored secret cannot be
  unlocked outside the organization it belongs to.
* **Masked on read-back.** When a credential is read, secrets are returned masked,
  never in clear: an `oauth2` credential reports the literal label `OAuth2`, an
  `api_key` reports a masked key, and a bearer token reports a masked token.

For the full picture — what's protected, key management, and the production
safeguards — see [Data security & encryption](/security/data-encryption).

<Note>
  The `modulex_key` (ModuleX-managed) credential stores only a **UUID** in its
  encrypted blob, not a real provider key. The UUID points into a system-managed key
  pool; the real key is fetched from the pool at resolution time and is never
  persisted on your credential. See the resolution rules below.
</Note>

## How credentials are resolved at run time

When a tool or LLM node executes, ModuleX resolves a credential in two phases:
**pick** one, then **prepare** it for execution (decrypt, refresh, and — for
managed keys — gate on credits).

### Phase A — pick a credential

<Steps>
  <Step title="Explicit credential_id wins">
    If the caller passes a `credential_id`, that exact credential is used. It must
    belong to the org and match the integration; otherwise resolution fails with "no
    credential found."
  </Step>

  <Step title="Otherwise, the default">
    If no id is given, the credential marked `is_default` for the
    `(organization, integration_name, integration_type)` is used.
  </Step>

  <Step title="Otherwise, most-recent valid">
    If there is no default, ModuleX falls back to any valid credential, ordered to
    prefer a **user credential over a `modulex_key`**, then most recent first.
  </Step>

  <Step title="Otherwise, fail">
    If nothing valid is found, resolution fails with "no credential found."
  </Step>
</Steps>

**Precedence summary:** explicit `credential_id` > `is_default` row > most-recent
valid **user** credential > most-recent valid `modulex_key`.

### Phase B — prepare for execution

The prepare step differs by whether the chosen credential is a managed key or one
you supplied.

<Tabs>
  <Tab title="User credential (oauth2 / api_key / bearer_token / custom)">
    1. Decrypt `auth_data`.
    2. If `auth_type` is `oauth2`, refresh the access token when it is within five
       minutes of expiring (re-encrypt, update, commit, and clear the Redis cache for
       the credential). This needs a `refresh_token` and the decrypted `oauth_config`.
    3. Return the decrypted `auth_data` for the tool function to use.

    **No credit check applies to user credentials.** Usage of a credential you supply
    is not metered in ModuleX credits — the provider bills you directly (BYOK).
  </Tab>

  <Tab title="ModuleX-managed key (modulex_key)">
    1. Decrypt the stored blob, which holds a `modulex_key` UUID (not a real key).
    2. Verify that UUID is registered for the `(organization, integration, key)` — if
       not, resolution fails with a verification error.
    3. Fetch the **real** API key from the system key pool.
    4. **Check the credit limit** for the org. The check happens here, before
       execution. If the org is over its limit, resolution raises a credit-limit
       error; with no active subscription it raises a resolution error.
    5. Return `auth_data` containing the real key. For `tool` integrations, post-
       execution usage logging is required so the call can be billed.

    A `modulex_key` credential **always** carries this credit gate — see
    [Usage gating & limits](/billing/usage-gating) and
    [Credits & metering](/billing/credits).
  </Tab>
</Tabs>

## Errors

Credential CRUD and OAuth endpoints raise the standard FastAPI
`HTTPException` envelope — `{"detail": <string>}` — for everything except the
org-level rate-limit deny, whose `detail` is a dict. These routes do **not** emit
the flat `DenialEnvelope`; the credit gate fires inside credential *resolution* at
run time, not on these CRUD routes. For the full taxonomy of all three error
envelope shapes and which surface emits each, see
[Errors & status codes](/api-reference/errors).

| Status        | When                                                                                                                                                           |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `201`         | Credential created.                                                                                                                                            |
| `204`         | Credential deleted.                                                                                                                                            |
| `302`         | OAuth2 callback — always a redirect back to the app, even on error.                                                                                            |
| `400`         | Missing `X-Organization-ID`; invalid `auth_data`/`auth_type`; integration has no OAuth2 schema; missing/partial composer linkage; invalid custom OAuth config. |
| `401` / `403` | Not authenticated; not a member; not owner/admin.                                                                                                              |
| `404`         | Integration or credential not found.                                                                                                                           |
| `429`         | Org-class `api` rate limit; includes `X-RateLimit-*` and `Retry-After` headers.                                                                                |
| `500`         | Unexpected error during create/test.                                                                                                                           |
| `503`         | OAuth state storage (Redis) unavailable at initiate.                                                                                                           |

<Note>
  There is **no `402`** on these credential routes. A `402` from credit exhaustion
  appears on the **run / composer / assistant / managed-knowledge** surfaces when a
  `modulex_key` credential is resolved during execution — that is where the credit
  gate is enforced. See [Usage gating & limits](/billing/usage-gating).
</Note>

## Next steps

<CardGroup cols={2}>
  <Card title="Credentials & OAuth2" icon="key" href="/concepts/credentials-oauth">
    The conceptual model and a guided walkthrough of connecting an OAuth integration.
  </Card>

  <Card title="Managing credentials" icon="gear" href="/integrations/managing-credentials">
    Create, set defaults for, test, and delete credentials in the app and over the API.
  </Card>

  <Card title="Manifest & schema contract" icon="file-code" href="/integrations/building/manifest-schema">
    The full pydantic contract behind the six auth schema variants.
  </Card>

  <Card title="Data security & encryption" icon="shield" href="/security/data-encryption">
    How ModuleX encrypts credentials and manages keys.
  </Card>
</CardGroup>
