Skip to content

Authentication ​

Español: Autenticación.

The core API authenticates integrations with per-tenant API keys, sent as a bearer token: no JWT, no roles on a key — the key is the tenant identity. Dashboard users sign in separately with email + password (POST /v1/auth/login, a ddu_ session).

Authorization: Bearer dd_...

Every endpoint except GET /healthz requires the header. Only the sha256 of the token is stored server-side (api_keys table, RLS-protected) — a leaked database never leaks usable keys.

Getting your first key ​

The first key for a tenant is minted at provisioning time by the Dialer Digital team (locally: priv/repo/seeds.exs, which prints the bearer token exactly once). From then on you rotate keys yourself through the API.

The dashboard signs people in with user accounts

The owner dashboard signs people in with dashboard user accounts (email + password at POST /v1/auth/login, which mints a ddu_ session; see the API Reference), and those accounts can add a second factor. Single sign-on through your own SAML identity provider is shipped in core v0.86.0 (published as v0.86.0-prod-0.1) (below); its "Sign in with SSO" button ships in dashboard v0.41.0 (dashboard#532).

Managing keys: /v1/api_keys ​

MethodPathNotes
GET/v1/api_keysList keys — id, label, revoked_at, created_at. Hashes are never exposed.
POST/v1/api_keysBody {"label": "..."}. Returns 201 with the new key and the plaintext token — shown once, never again.
DELETE/v1/api_keys/{id}Revokes the key (sets revoked_at). Keys are never deleted — the audit trail keeps them.

Minting and revoking take an account_admin dashboard login. A member login gets 403 role_forbidden, and so does a dd_ key as soon as the account has an active account_admin (core#2359, core#2449); an account with no active account_admin can still mint its first key with a dd_ key. The platform setting api_key_manages_people (off by default) gives the key its earlier reach back. Listing is not affected. See the warning in Dashboard users.

Mint a new key ​

bash
curl -s -X POST $API/v1/api_keys \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"label": "warehouse-etl"}'
json
{
  "api_key": {
    "id": "7c0e6a1e-3f2b-4d5c-9e8f-1a2b3c4d5e6f",
    "label": "warehouse-etl",
    "revoked_at": null,
    "created_at": "2026-06-11T14:30:00Z"
  },
  "token": "dd_kQ3v…plaintext-shown-ONCE…"
}

Store token immediately — there is no way to retrieve it later.

Rotation recipe ​

  1. POST /v1/api_keys with a fresh label → get the new plaintext.
  2. Deploy the new token to your integration.
  3. DELETE /v1/api_keys/{old-id} → old key stops working immediately.

Revocation also kills live WebSocket connections: each heartbeat re-authenticates the token, so a revoked key is dropped within one heartbeat (~30 s) with close code 1008.

Second factor (MFA) ​

Dashboard users (ddu_ sessions, not API keys) can protect their account with a TOTP authenticator app (RFC 6238) plus single-use recovery codes. It is optional per user. API keys (dd_) are never affected. This shipped in core v0.86.0 (published as v0.86.0-prod-0.1); the routes are in the API Reference.

Login with a factor active. POST /v1/auth/login answers 200 with mfa_required: true and a one-shot challenge instead of a session. The challenge opens no other route. Redeem it at POST /v1/auth/mfa/verify with {"challenge": "...", "code": "123456"} or, once, {"challenge": "...", "recovery_code": "..."} (exactly one of the two); the response is the same as a one-step login and carries the ddu_ token. The challenge dies after 5 minutes, after 5 wrong codes, or if the password changes in between, and an unknown, expired or spent challenge is one identical 401. A TOTP code is accepted at most once. A user without a factor logs in in one step, as before.

Managing your own factor (all need a ddu_ session; a dd_ key gets 403):

MethodPathNotes
GET/v1/me/mfaYour second-factor state.
POST/v1/me/mfa/enrollBody {"password": "..."}. Returns the secret and its otpauth:// URI (the QR payload), shown once; pending until confirmed. 409 if a factor is already active.
POST/v1/me/mfa/confirmBody {"code": "..."}. Activates the factor and returns the ten recovery codes, shown once (only their hash is stored).
POST/v1/me/mfa/disableReauthentication: current password plus a TOTP code or a recovery_code.
POST/v1/me/mfa/recovery-codesSame reauthentication. Revokes the unused codes and issues a new set, shown once.

Wrong codes and wrong passwords are rate-limited per user (429 with Retry-After), shared by every route above that compares a code or password.

Password blocklist. Wherever a console password is set (accepting an invitation and confirming a reset at POST /v1/auth/password-reset/confirm, POST /v1/me/password, and the demo provisioning), it is checked after the length policy (12 to 128 characters) against a local list of commonly used or compromised passwords, with case and leetspeak folded so Password123456 and P4ssw0rd99 are caught. A password with fewer than four distinct characters, or one containing the person's email local part or the workspace name (fragments of 4 or more characters), is refused too. The answer is 422 with code password_blocklisted and the reason in the message (common, repetitive, contains_email or contains_tenant); nothing changes and an invitation or reset link stays redeemable. Existing passwords are never checked. The platform setting password_blocklist_enforced ships on.

Progressive brake per account. On top of the per-user rate limit, POST /v1/auth/login counts the bad_password rows of the sign-in audit trail for the person since their last successful sign-in, within 24 hours. At 20 (login_progressive_threshold_failures) or more, the login answers 429 with Retry-After without comparing anything, not even the correct password, until 15 minutes after the last failure; the pause doubles for every further 20 failures and is capped at 6 hours. A successful sign-in resets the count. It is a pause, not a lock, so someone who only knows an admin's email cannot shut that admin out for good. Held attempts are not recorded, so they do not lengthen the wait.

Required by the tenant. A per-tenant setting, mfa_required_enforced, ships off. When it is turned on for a tenant (a platform-side setting, not writable with a tenant key or session), a user with no factor gets mfa_required: true with purpose: enroll at login and must enrol (POST /v1/auth/mfa/enroll, then POST /v1/auth/mfa/enroll/confirm) before a session is minted.

Recovering a person who lost both the authenticator and the recovery codes ​

This path was published in core v0.88.0 (core#1580) and is in the generated API Reference. An account_admin signed in with a ddu_ session can remove another active person's factor in the same tenant:

MethodPathNotes
POST/v1/users/{id}/mfa/removeBody {"reason": "..."}. The reason is required free text, trimmed and kept in the audit trail.

The operation invalidates the active factor and its unused recovery codes, revokes every live ddu_ session of the person, and appends a factor_removed_by_admin event with the admin, target and reason. These effects share one transaction: if the event or session revocation cannot be written, the factor remains active. Existing audit events are not deleted.

A tenant API key (dd_) and a member get 403; a person outside the tenant is indistinguishable from one that does not exist (404). An admin cannot use this route on themself (409): the self-service path remains POST /v1/me/mfa/disable, with reauthentication. If the tenant requires MFA, the recovered person's next password login starts the forced enrolment flow before a new session can be minted. Otherwise they can sign in without a factor and enrol again later.

Single sign-on (SAML) ​

Each tenant can sign its people in through its own SAML 2.0 identity provider (IdP), SP-initiated. This shipped in core v0.86.0 (published as v0.86.0-prod-0.1); the routes are in the API Reference. The dashboard's "Sign in with SSO" button ships in dashboard v0.41.0 (dashboard#532); the flow below is the API contract. API keys (dd_) are never affected.

It is dark until the cell is configured. The cell needs SSO_SP_BASE_URL, the public base URL through which a browser reaches core. Without it the metadata, start and ACS routes answer 404, and GET /v1/sso/idp-config returns service_provider: null.

Configure your IdP (needs an account_admin user with a ddu_ session). A dd_ key cannot read or write this configuration, so a leaked integration key cannot point the login at an attacker's IdP: it gets 403 role_forbidden.

MethodPathNotes
GET/v1/auth/sso/{tenant_id}/metadataPublic. The service-provider metadata XML to paste into your IdP: entity ID, ACS URL (HTTP-POST binding) and emailAddress NameID format, both derived from your tenant id.
GET/v1/sso/idp-configYour IdP configuration (or null) plus the service_provider block with the same URLs.
PUT/v1/sso/idp-configBody idp_entity_id, sso_url (must be https://), certificates (one or two PEM certificates: the only keys a signature is accepted from), require_signed_assertion, require_signed_response, email_attribute (optional) and enabled.
PATCH/v1/sso/idp-configBody {"enabled": true | false}.
DELETE/v1/sso/idp-configRemoves the configuration.
GET/v1/sso/idp-config/eventsThe audit history of changes to the configuration.

The login. Three steps, so that a forged form posted into a victim's browser cannot sign them in as the attacker:

  1. POST /v1/auth/sso/start with {"tenant_id": "..."} returns the IdP URL to send the browser to (redirect_url) and a browser_secret that belongs to that browser only. A tenant without an enabled IdP gets one 404, indistinguishable from an unknown tenant.
  2. The IdP posts its response to the tenant's ACS, POST /v1/auth/sso/{tenant_id}/acs. Core validates it and answers a 303 back to the dashboard's /sso/callback, with a one-shot code in the URL fragment (or error=sso_failed, one generic answer for every rejection). The ACS never mints a session.
  3. POST /v1/auth/sso/exchange with {"code": "...", "browser_secret": "..."} mints the usual ddu_ session. The code lives 120 seconds, is spent on its first presentation whether or not the secret matched, and an unknown, expired or spent code is one identical 401.

What is checked. The signature must verify against a certificate you pinned (the key inside the document is ignored; RSA with SHA-256 or stronger, SHA-1 is rejected), and it must cover the element the data is read from, so a wrapped or duplicated assertion is rejected. Audience, recipient and destination must be this tenant's own URLs, the response must answer the request that started the login (InResponseTo), the validity window must hold, and an assertion ID is accepted once. A response issued for one tenant's SP is worthless at another's.

No just-in-time provisioning. The person must already be a user of the tenant: the assertion's email is matched against it, and an unknown email is rejected without creating anything. Disabled users stay disabled. An SSO login does not ask for the app's own second factor: the IdP is the authority.

Requiring SSO. A per-tenant setting, sso_required_enforced, ships off. When a platform operator turns it on for a tenant that also has an enabled IdP, POST /v1/auth/login answers 403 with code sso_required after password authentication succeeds, and SSO is the only way in. With the setting on but no enabled IdP it does nothing, so a broken IdP configuration cannot lock a tenant out by itself. API keys, widget tokens and password reset are untouched.

Sign-in audit trail ​

Every sign-in of a dashboard person (ddu_ sessions, never API keys) leaves a row in an append-only trail: by password, by second factor and by SSO, successful or refused. A database trigger rejects any UPDATE or DELETE, so a row cannot be edited or removed through the API or by the application's own database role. The route reaches the API Reference when its vendored OpenAPI is refreshed to the core release that carries core#1520; the behaviour below is that release's.

Reading it (needs an account_admin user with a ddu_ session). A member and a dd_ key both get 403 role_forbidden: the trail names people and network addresses, and an integration key is not a person.

MethodPathNotes
GET/v1/auth/sign-in-eventsNewest first. Filters user_id, outcome (success or failure), since (inclusive) and until (exclusive), both RFC 3339. limit defaults to 50 and is capped at 200; next_cursor in the response is the cursor of the next page, null on the last. A filter that does not parse is a 400 naming it, never an empty page.

What a row says. method (password, mfa or sso), outcome, a reason from a closed list (bad_password, unknown_user, disabled, suspended, sso_required, mfa_failed, sso_rejected, mfa_pending), ip, user_agent (truncated to 256 characters), the session_id the login minted, and occurred_at. An accepted password for a person who owes a second factor (or its enrolment) is recorded as method: password, outcome: success, reason: mfa_pending (a granted step, no session yet); the session itself appears with the mfa row that redeems the challenge.

An address nobody owns is never stored in clear. When the address does not match any person, the row has user_id: null and email_attempted holds only a SHA-256 digest of the normalized address taken with a fixed purpose prefix (not the bare SHA-256 of the address); such rows are not readable through this route (they belong to no tenant).

Fail-closed. A successful sign-in is written in the same transaction that mints its session: if the trail cannot be written, the sign-in is refused. A refusal is written best-effort after the refusal, so the answer to the caller is the same either way.

No retention yet. The trail only grows; a purge policy is not part of this feature.

Failure modes ​

StatusCodeWhen
401unauthorizedMissing, malformed, unknown, or revoked key.
403forbiddenValid key, but the tenant is suspended.

WebSocket authentication: an ephemeral ticket ​

Browsers cannot set headers on a WebSocket handshake, so something has to ride the query string. /v1/ws accepts exactly one thing there, and it is not your API key: a single-use ticket that expires in seconds. Minting it is one extra call, and that call is the whole point — the long-lived key stays in an Authorization header, where it belongs.

Step 1 — mint the ticket, presenting the API key the normal way:

POST /v1/ws/ticket
Authorization: Bearer dd_...
json
{
  "ticket": "ddtt_8Zr3kQ0m2sVx1yPd7La9WfHnB6TcEjRu4KgMoZvQiXs",
  "expires_at": "2026-01-01T00:00:30Z",
  "expires_in": 30
}

expires_in is the ticket's life in seconds, and it is deliberately short — read it from the response rather than assuming a value.

If a live-floor connection cap is already full, this call mints nothing and answers 429 too_many_connections with an error.scope of widget_token or tenant — see Rate limits & caps.

Step 2 — open the socket with the ticket:

GET /v1/ws?ticket=ddtt_...

The ticket inherits the exact principal of the bearer that minted it, and its lane is encoded in the prefix — ddtt_ for a tenant key, ddta_ for an agent one. A ticket minted by an agent credential can never open a tenant socket.

403 (suspended tenant) and 426 (the request is not an upgrade) behave as everywhere else. 401 covers three cases with one identical response — unknown ticket, already redeemed, and expired — so a client learns nothing from the difference. The socket's topic comes from the redeemed ticket: a socket only ever receives its own tenant's events, and there is nothing to subscribe to by name. Details in WebSocket events.

Why a ticket, and not the key

Anything in a URL ends up in access logs, browser history and Referer headers. Rather than ask you to mitigate that, the API makes what rides there worthless: the ticket is burned on first redemption and expires in seconds even if it is never used. Mint one per connection, and nothing reusable ever reaches a log.

Tenancy model ​

  • One key ↔ one tenant. GET /v1/me returns the tenant (not a user).
  • GET /v1/tenants returns a one-element list (your own tenant) — kept for dashboard parity.
  • The API never accepts a tenant id from path, query, or body. Another tenant's resource id reads as 404 not_found — existence is never leaked (enforced by Postgres RLS, not by handler-level id comparison).

Security checklist for integrators ​

  • Store tokens server-side only; never embed them in code shipped to consumers.
  • Mint one key per integration (label it) so revocation is surgical.
  • Rotate via the recipe above on your normal credential schedule.
  • Log the X-Request-ID of failed calls — it makes support cases instant.

Nothing in these docs is legal advice — always confirm compliance posture with your own counsel.