Credentials
The credentials module lets an operator define a reusable
credential configuration once and reference it from many places. Instead of
re-typing an API key into the AI-assistant settings, the search-embedder
settings, and the newsletter settings, you create a single Primary LLM
configuration and point each setting at it. Change the key once — every
consumer picks up the new value with no further edits.
Secret fields (API keys, passwords) are encrypted at rest, write-only at the boundary (never returned in plaintext), masked on every read, and redacted in the audit log. Every create / edit / delete is audited through the Command Bus.
For Product Owners & operators
What a credential configuration is
A configuration is a named, coded instance of a configuration type. Two types ship out of the box:
- LLM — providers GPT (OpenAI), Gemini (Google), Claude (Anthropic), and DeepSeek. Fields: API Key (secret, required), Model (required), Base URL (optional).
- Email adapter — providers SMTP, Amazon SES, and SendGrid.
Each provider has its own field set with exactly one secret field
(SMTP
password, SESsecretAccessKey, SendGridapiKey).
The type and provider you pick decide which fields the form shows.
Creating a configuration
- Open Credentials in the admin sidebar (visible with the
credentials:readpermission). - Click New configuration.
- Pick a Type (e.g. LLM) and a Provider (e.g. Claude). The form redraws to that provider's fields.
- Fill in a Name (e.g.
Primary LLM) and a Code (a stable identifier likeprimary-llm— this is what settings reference), then the fields (API Key, Model, …). - Save. The configuration appears in the list; every secret field shows as set — never the value.
Editing works the same way. On edit, the type and provider are locked (they cannot change — create a new configuration instead). Leaving a secret field blank keeps the stored secret; typing a new value replaces it.
Using a configuration from a setting
Some settings are of the "credential reference" kind, constrained to one configuration type. Such a setting renders as a picker of matching configurations plus a Preview button:
- Open Settings and find the credential-reference setting (e.g. an AI assistant's LLM credentials).
- Select your configuration from the dropdown — only configurations of the right type are offered.
- Click Preview to see the referenced configuration read-only (secrets masked) without leaving the page.
Assign the same configuration to several settings to reuse it. Update the configuration's key once and all of them resolve the new value.
Safety
- Delete is blocked while referenced. Trying to delete a configuration that a setting still points at fails with a clear message listing exactly which settings to detach first.
- Orphaned configurations (whose type is no longer available) are shown read-only ("unavailable") and never crash the screen.
- No plaintext ever leaves the server — list, detail, and preview all mask secrets; only the server-side consumer path decrypts, in memory, for use.
For developers
Model
A configuration type is a code-registered descriptor (not database rows):
interface ConfigurationTypeDescriptor {
code: string; // 'llm' | 'email_adapter' | …
label: string;
ownerModule: string;
providers: ProviderVariant[];
}
interface ProviderVariant { code: string; label: string; fields: FieldDefinition[]; }
interface FieldDefinition {
key: string;
label: string;
kind: 'string' | 'number' | 'boolean' | 'select';
required: boolean;
secret: boolean; // secret ⇒ encrypted at rest, masked on read
options?: { value: string; label: string }[];
placeholder?: string;
}
A saved configuration stores typeCode + providerCode + a values bag
(secret fields hold AES-256-GCM envelopes, non-secret fields hold plain
scalars) in the credential_configurations table (@GlobalEntity,
platform-global).
The credentials core is type-agnostic: it reads
a descriptor only to render fields, derive the write-validator, and learn which
fields are secret. It never branches on a specific typeCode / providerCode —
provider meaning lives with the consumer.
Registering a new type (the extension point)
Register from any module's install path via the process-wide singleton — no change to the credentials core is required (overlay-safe):
import { configurationTypeRegistry } from '@core/modules/credentials/services/registry-singleton.js';
configurationTypeRegistry.register({
code: 'sms_gateway',
label: 'SMS Gateway',
ownerModule: 'my_module',
providers: [
{
code: 'twilio',
label: 'Twilio',
fields: [
{ key: 'accountSid', label: 'Account SID', kind: 'string', required: true, secret: false },
{ key: 'authToken', label: 'Auth Token', kind: 'string', required: true, secret: true },
],
},
],
});
The type immediately appears in the admin type picker (GET /api/v1/admin/credentials/types) and is creatable.
The credential_ref settings value type
To let a setting reference a configuration, declare it in a module's settings manifest:
{
code: 'prompt_actions.llm_credentials',
name: 'LLM credentials',
valueType: 'credential_ref',
configurationType: 'llm', // only configurations of this type are selectable
defaultValue: '', // empty ⇒ not configured
}
configurationType is required for credential_ref settings and forbidden
otherwise. The stored value is simply the configuration code.
Resolving a reference (consumer side)
Resolution is a server-side service call (never an HTTP response) — the one path that returns decrypted secrets, for in-memory use only:
const code = await settings.get('prompt_actions.llm_credentials'); // → 'primary-llm'
const cred = code ? await credentials.resolve(code) : { status: 'not_configured' };
if (cred.status !== 'ok') throw new Error('LLM not configured'); // fail closed
callProvider(cred.providerCode, cred.values.apiKey, cred.values.model);
resolve returns a discriminated result:
{ status: 'ok', typeCode, providerCode, values }— secrets decrypted;{ status: 'not_configured' }— the reference is unset/empty;{ status: 'unavailable', reason: 'missing' | 'inert_type' }— a deleted code or an unregistered type.
A consumer never receives a foreign configuration and never a plaintext secret over the wire.
Delete integrity
CredentialsService.delete first calls
SettingsService.listReferencesToConfiguration(code) (the only channel by which
credentials reaches settings). A non-empty result blocks the
delete with 409 CREDENTIAL_IN_USE, carrying { referencedBy: [{ settingCode, salesChannelCode? }] }.
Secrets & configuration
Secret fields reuse the platform's AES-256-GCM envelope codec keyed by the
existing SETTINGS_SECRET_ENCRYPTION_KEY env var — no new secret to
provision. Writing a secret without the key fails closed
(SETTING_SECRET_KEY_MISSING); boot warns if the key is unset.
Permissions
credentials:read— view configurations and the type catalogue (secrets masked).credentials:write— create / edit / delete configurations, including writing secret fields.