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 underauth_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 |
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 ofauth_type, inherits the same base fields.
Human-readable label shown in the connect UI (e.g.
"OAuth2 Authentication").One-line explanation of the method shown next to the label.
An ordered list of steps shown to the person connecting the integration (e.g. how
to create a personal access token).
Operator- or user-supplied secrets and settings for this method. See
EnvVar below.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.
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.
Env-var-style key (e.g.
GITHUB_OAUTH2_CLIENT_ID).Label shown in the UI.
Help text shown under the field.
Whether the value must be supplied. Note this defaults to
true — the opposite of
ParameterDef.required, which defaults to false.Whether the value is a secret. Sensitive values are masked in the UI.
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.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.Placeholder hint shown in the input (e.g.
"ghp_xxxx...").Link to where the user obtains the value.
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.
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 withauth_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 plusapi_key: strdirectly (often sent asx-api-key: {api_key}), and checks the key is non-empty before calling out.
@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.
The integration to connect. Must expose an
oauth2 auth schema, or the request
fails with 400.true uses the ModuleX-registered OAuth app for the provider (the managed path).
false uses your own OAuth app and requires custom_oauth_config.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.The callback URL the provider redirects to after consent. This is the ModuleX
backend callback (e.g.
https://api.modulex.dev/credentials/oauth2/callback).Space-separated scopes. Defaults to the provider/schema scopes joined by spaces.
A label for the credential that gets created.
Whether the new credential becomes the default for its integration.
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 auto-resume linkage. Must be supplied together with
composer_request_id and composer_llm_config; supplying some but not all returns
400.Composer auto-resume linkage — see
composer_chat_id.Composer auto-resume linkage — see
composer_chat_id.The provider authorize URL to open in the browser. Includes the
state and PKCE
code_challenge.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.Step 2 — provider consent and callback
The browser opensauthorization_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:
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.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.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.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 itsexpires_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.
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.Per-credential auth data — HKDF + Fernet
Per-credential auth data — HKDF + Fernet
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 keyENCRYPTION_KEY. - The data is JSON-serialized and sealed with Fernet (AES-128-CBC + HMAC-SHA256).
ENCRYPTION_KEY is required in production and must be at least 32 characters
(256 bits).OAuth provider client secrets — PBKDF2 + Fernet
OAuth provider client secrets — PBKDF2 + Fernet
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.Key versioning and masking
Key versioning and masking
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.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
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.”Otherwise, the default
If no id is given, the credential marked
is_default for the
(organization, integration_name, integration_type) is used.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.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.- User credential (oauth2 / api_key / bearer_token / custom)
- ModuleX-managed key (modulex_key)
- Decrypt
auth_data. - If
auth_typeisoauth2, 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 arefresh_tokenand the decryptedoauth_config. - Return the decrypted
auth_datafor the tool function to use.
Errors
Credential CRUD and OAuth endpoints raise the standard FastAPIHTTPException 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.
| 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 (fail-open); includes X-RateLimit-* and Retry-After headers. |
500 | Unexpected error during create/test. |
503 | OAuth 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.