Skip to main content
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. To create, set defaults for, test, and delete credentials in the app and over the API, see Managing credentials.
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.

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_typeVariant classWhat you supplyTypical use
oauth2OAuth2AuthSchemaNothing directly — you complete a consent flowGitHub, Google Calendar, Netlify
bearer_tokenBearerTokenAuthSchemaA long-lived token (e.g. a personal access token)GitHub PAT
api_keyApiKeyAuthSchemaA provider-issued API keyExa, ConvertAPI, Hunter, Freshdesk
modulex_keyModulexKeyAuthSchemaNothing — ModuleX supplies a managed key, billed in creditsExa (managed), Firecrawl, Jina AI, Hacker News
customCustomAuthSchemaFree-form fields defined by the integrationPostgreSQL, WooCommerce, Coinbase
internalInternalAuthSchemaReserved — not supplied by youSystem-managed only
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.
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.

Shared fields on every variant

Every auth schema, regardless of auth_type, inherits the same base fields.
display_name
string
required
Human-readable label shown in the connect UI (e.g. "OAuth2 Authentication").
description
string
required
One-line explanation of the method shown next to the label.
setup_instructions
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).
setup_environment_variables
EnvVar[]
default:"[]"
Operator- or user-supplied secrets and settings for this method. See EnvVar below.
test_endpoint
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.

OAuth2 variant (oauth2)

OAuth2AuthSchema adds a single extra field, oauth_config, that describes the authorization-code flow.
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 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.

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 below and Credits & metering). 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.
name
string
required
Env-var-style key (e.g. GITHUB_OAUTH2_CLIENT_ID).
display_name
string
required
Label shown in the UI.
description
string
required
Help text shown under the field.
required
boolean
default:"true"
Whether the value must be supplied. Note this defaults to true — the opposite of ParameterDef.required, which defaults to false.
sensitive
boolean
default:"false"
Whether the value is a secret. Sensitive values are masked in the UI.
only_for_custom
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.
inject_into_auth_data
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.
sample_format
string | null
default:"null"
Placeholder hint shown in the input (e.g. "ghp_xxxx...").
about_url
string | null
default:"null"
Link to where the user obtains the value.
The interaction of the two flags determines handling:
inject_into_auth_dataonly_for_customBehavior
false (default)anyOAuth/test-only; never reaches a tool function.
truefalsePer-credential user input, persisted into auth_data at credential creation.
truetrueServer-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. 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 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 and Manifest & schema contract.

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.
🎬 MEDIA PLACEHOLDER · MX-MEDIA-4020 · [IMAGE] [IMAGE_DESCRIPTION]: Sequence diagram of the OAuth2 PKCE connect flow. [IMAGE_DETAILS]: Five lanes left to right — User/Browser, ModuleX app (frontend), ModuleX backend, Redis (5-min state store), OAuth provider. Show: (1) POST /credentials/oauth2/initiate returning authorization_url + state, with code_verifier/code_challenge (S256) generated and flow state written to Redis with a 5-minute TTL; (2) browser redirected to the provider, user consents; (3) provider redirects browser to GET /credentials/oauth2/callback?code&state; (4) backend one-time reads + deletes the Redis state, exchanges code at token_url (PKCE code_verifier), encrypts and stores the credential; (5) backend issues a 302 back to the frontend landing page with ?status=success&credential_id=<uuid>. Annotate the 5-minute TTL and the one-time state read. Light theme, 16:9.

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.
integration_name
string
required
The integration to connect. Must expose an oauth2 auth schema, or the request fails with 400.
use_modulex_oauth
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.
custom_oauth_config
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.
redirect_uri
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).
scope
string
Space-separated scopes. Defaults to the provider/schema scopes joined by spaces.
display_name
string
A label for the credential that gets created.
make_default
boolean
default:"false"
Whether the new credential becomes the default for its integration.
env_var_values
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.
composer_chat_id
string
Composer auto-resume linkage. Must be supplied together with composer_request_id and composer_llm_config; supplying some but not all returns 400.
composer_request_id
string
Composer auto-resume linkage — see composer_chat_id.
composer_llm_config
object
Composer auto-resume linkage — see composer_chat_id.
Authenticate with a key or a Clerk JWT plus the org header, exactly as for any ModuleX API call (see Authentication):
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>"}
authorization_url
string
The provider authorize URL to open in the browser. Includes the state and PKCE code_challenge.
state
string
A one-time CSRF/correlation token. ModuleX stores the flow state in Redis keyed by this value with a 5-minute TTL.
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.
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:
1

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

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

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

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

How credentials are stored (encrypted at rest)

Credentials are never stored in plaintext. ModuleX uses two distinct encryption schemes depending on what is being protected.
The auth_data and oauth_config you supply for a credential are encrypted with a key derived per credential:
  • The key is derived with HKDF-SHA256 (length 32), scoped to (organization_id, credential_id), from the server master key ENCRYPTION_KEY.
  • The data is JSON-serialized and sealed with Fernet (AES-128-CBC + HMAC-SHA256).
Because the key is scoped to the specific organization and credential, ciphertext cannot be moved between credentials — it fails to decrypt. The master ENCRYPTION_KEY is required in production and must be at least 32 characters (256 bits).
The ModuleX-registered OAuth app client ids and secrets (used by the managed OAuth path) are encrypted with PBKDF2-HMAC-SHA256 (length 32, 100000 iterations) using a random 16-byte salt per value, then sealed with Fernet. The output is base64url(salt || fernet_token). Decryption tries the salt-prepended format first and falls back to a legacy static salt for older values.
Each encrypted table carries an encryption_key_version column that defaults to 1 — scaffolding for future key rotation; it is currently always 1. When a credential is read back, secrets are masked, never returned in clear: an oauth2 credential reports the literal label OAuth2; an api_key reports a masked key; a bearer_token/bearer reports a masked token; anything else reports Credential.
For the broader encryption and key-management posture, see Data security & encryption.
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.

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

1

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.”
2

Otherwise, the default

If no id is given, the credential marked is_default for the (organization, integration_name, integration_type) is used.
3

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

Otherwise, fail

If nothing valid is found, resolution fails with “no credential found.”
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.
  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).

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

Next steps

Credentials & OAuth2

The conceptual model and a guided walkthrough of connecting an OAuth integration.

Managing credentials

Create, set defaults for, test, and delete credentials in the app and over the API.

Manifest & schema contract

The full pydantic contract behind the six auth schema variants.

Data security & encryption

How ModuleX encrypts credentials and manages keys.