Skip to main content

Security model

This page is the map of Converger's security controls: who can authenticate with what, how tenants are kept apart, how secrets are stored, and which protections sit at the HTTP edge. Each section links to the page or ADR with the details. Client IPs, reverse proxies, the admin IP whitelist and leaked-secret rotation are covered in depth in Security: client IPs, proxies and admin access; this page does not repeat them.

Actors and credentials​

ActorCredentialWhere it is checkedStored as
Operator (admin panel /admin)Email + password, plus a TOTP code when 2FA is on; server-side sessionConvergerWeb.Plugs.AdminAuth (IP whitelist), ConvergerWeb.Plugs.Auth (session), LiveView on_mount hooksadmin_users.password_hash (bcrypt), admin_users.totp_secret (AES-GCM encrypted), user_sessions.token_hash (SHA-256)
Tenant staff (portal /portal)Tenant name + email + password, plus a TOTP code when 2FA is on; server-side sessionConvergerWeb.Plugs.Auth, LiveView on_mount hookstenant_users.password_hash (bcrypt), tenant_users.totp_secret (AES-GCM encrypted), user_sessions.token_hash (SHA-256)
Tenant backend (server to server)x-api-key: cvg_live_...: the primary key (every scope) or a scoped key (read, write, manage)ConvergerWeb.Plugs.TenantAuth (authentication and scope)tenants.api_key_hash, api_keys.key_hash (SHA-256)
Platform automation (admin API /api/v1/admin)ADMIN_API_TOKEN as Authorization: Bearer ...ConvergerWeb.Plugs.AdminAuth (IP whitelist), ConvergerWeb.Plugs.AdminApiAuthEnvironment variable only
Channel integrationChannel secret as Authorization: Bearer ... on POST /api/v1/converger/tokens/generateConvergerWeb.Plugs.ConvergerAuth (mode: :secret)channels.secret (AES-GCM encrypted) + channels.secret_hash (SHA-256, for lookup)
Browser without a backendPublishable key cvg_pk_... as Authorization: Bearer ... on POST /api/v1/converger/tokens/publishable, from an allowed Origin; mints anonymous, conversation-bound tokens onlyConvergerWeb.ConvergerAPI.PublishableTokenControllerchannels.publishable_key (public, plain text) + channels.allowed_origins (publishable keys)
End-user client (widget, SDK)Converger token (JWT) as Bearer or socket token paramConvergerWeb.Plugs.ConvergerAuth (mode: :token), ConvergerWeb.ConvergerSocketNot stored (JWT); revocations and rotations in revoked_tokens (client tokens)
Backend client on the gRPC transportConverger token (JWT) as authorization: Bearer metadata or Hello.tokenConvergerWeb.Protocol.Session on hello (Protocol v1 over gRPC)Not stored (JWT); revocations in revoked_tokens
Legacy client (deprecated, see migrating)Channel token in x-channel-token, conversation token on /socketConvergerWeb.Plugs.TenantAuth, ConvergerWeb.UserSocketNot stored (stateless JWT)
External provider (WhatsApp, webhook sender)Request signature over the raw bodyConvergerWeb.InboundController + Converger.Channels.InboundSignature or the adapter's own schemeUses the channel secret / adapter config

Authentication​

Admin users​

Admin accounts live in admin_users with the roles super_admin, admin and viewer (Converger.Accounts.AdminUser). Passwords are hashed with bcrypt (bcrypt_elixir, default cost) through Converger.Accounts.PasswordHasher and follow the password policy. Development builds without a C compiler can use PBKDF2-SHA512 instead (CONVERGER_PASSWORD_HASHER=pbkdf2, ADR-0057). Converger.Accounts.authenticate_admin/2 runs PasswordHasher.no_user_verify/0 when the email is unknown, so response time does not reveal whether an account exists.

Every /admin route goes through two layers:

  1. ConvergerWeb.Plugs.AdminAuth rejects clients whose IP is not in ADMIN_IP_WHITELIST (default 127.0.0.1,::1) with 403. The client IP is only taken from X-Forwarded-For when the TCP peer is in TRUSTED_PROXIES (see ../security.md).
  2. The session plugs (fetch_admin_user, require_admin_user) and the LiveView hook ConvergerWeb.Live.AuthHooks.ensure_admin_user require a valid server-side session of an active admin user. Admins whose role must use 2FA and have not enabled it are sent to /admin/security first.

There is no default password. The first super_admin is created by Converger.Release.seed_admin/0 (or priv/repo/seeds.exs in development) from ADMIN_EMAIL / ADMIN_PASSWORD. When ADMIN_PASSWORD is unset a random password is printed once and the account gets must_change_password: true. That flag is set programmatically (never cast from form input); while it is set, require_admin_user redirects every admin page to /admin/password, and changing the password clears it. See Initial admin account.

The Oban Web dashboard at /admin/oban uses the same pipelines, and ConvergerWeb.ObanResolver maps roles to access: super_admin and admin get full access, viewer is read-only.

Tenant users​

Tenant users (tenant_users, roles owner, admin, member, viewer) sign in at /portal/login with the tenant name, email and password. The portal is not behind the IP whitelist. Email uniqueness is scoped to the tenant, and the tenant must be active. Authorization inside the portal is role based (viewers are read-only, owners and admins manage users). Tenant users can enable 2FA and reset a forgotten password like admins; the portal's own password change is on /portal/security.

Sessions​

Logins are server-side sessions (user_sessions, ADR-0058). A login stores a random 32-byte token in the Phoenix cookie session (_converger_key, signed with SECRET_KEY_BASE, SameSite=Lax) and only its SHA-256 in the table, with the client IP, user agent, login time and last activity. ConvergerWeb.Plugs.Auth and the LiveView on_mount hooks check on every request and mount that the row exists, belongs to an active user and is within:

  • the absolute lifetime (SESSION_MAX_AGE_SECONDS, default 12 hours from login; also the cookie max_age),
  • the idle timeout (SESSION_IDLE_TIMEOUT_SECONDS, default 30 minutes since the last request; last_seen_at is updated at most once a minute).

Each LiveView connects with the session's live_socket_id (user_sessions:<id>). Deleting a session broadcasts disconnect to it, so open LiveViews close at once. Sessions are deleted on logout, by Log out everywhere and per-device Sign out on /admin/security and /portal/security, on a password change (all but the current one), on a password reset, when the user is deactivated or deleted, and by a super_admin from the admin users page. Converger.Workers.SessionPruneWorker deletes expired rows every 10 minutes and disconnects their LiveViews. The cookie session is renewed (configure_session(renew: true)) on login and logout, and the :browser pipeline enables CSRF protection (protect_from_forgery).

Two-factor authentication (TOTP)​

Admin and tenant users can turn on TOTP (RFC 6238: 6 digits, 30-second steps, SHA-1, any authenticator app) on their security page (/admin/security, /portal/security). Enrollment stores a random 20-byte secret, encrypted at rest like other secrets (Secrets at rest), shows it in base32 together with an otpauth:// URI for manual entry, and turns 2FA on only after a valid code. Turning it off needs a current code or the password.

With 2FA on, a correct password only creates a pending login (five minutes, in the cookie session); the session is created after /admin/login/totp or /portal/login/totp accepts a code. Codes of the current and the previous step are accepted, and the accepted step is stored (totp_last_step) with a conditional update, so a code works once, even under concurrent requests. Wrong codes count as failed logins (login lockout) for the client IP and the account <type>-2fa:<user id>.

Admin roles listed in ADMIN_REQUIRE_2FA_ROLES (default super_admin) must enable 2FA: until they do, every admin page redirects to /admin/security. Lost authenticator: a super_admin resets another admin's 2FA from Admin Users, a tenant owner or admin resets a team member's on Users, and an operator can reset any admin from a release shell:

bin/converger eval 'Converger.Release.reset_admin_2fa("admin.com")'

Enabling, disabling and resetting 2FA are audit-logged (enable_2fa, disable_2fa, reset_2fa).

Password policy​

Converger.Accounts.PasswordPolicy applies whenever a password is set (account creation, password change, reset) for both admins and tenant users:

  • at least PASSWORD_MIN_LENGTH characters (default 12);
  • at most 72 bytes, the bcrypt input limit (longer passwords would be truncated silently);
  • it must not equal or contain the account's email address.

Existing password hashes are not re-checked: users keep their passwords until they change them.

Password reset​

/admin/password/reset (behind the admin IP whitelist) and /portal/password/reset (tenant name + email) mail a reset link from MAILER_FROM through Converger.Mailer (SMTP_*, see Configuration). The form answers the same way whether or not an account matches, and requests are rate limited per client IP (password_reset_ip, 429) and per account (password_reset_account; over the limit no mail is sent, the answer does not change). The link carries a random 32-byte token (URL-safe base64); only its SHA-256 is stored (account_tokens). A link works once, for one hour, and only while the account still has the email it was sent to; any password change voids it. A reset clears must_change_password, ends every session of the user and is audit-logged (reset_password). Password changes are audit-logged too.

Tenant API keys​

A tenant API key is cvg_live_ followed by 32 random bytes (URL-safe base64). Only its SHA-256 digest is stored (tenants.api_key_hash) together with a short display prefix (api_key_prefix, shown as cvg_live_abcd****). The plaintext is returned once, when the tenant is created or the key is rotated, and can never be shown again.

Rotation (Converger.Tenants.rotate_api_key/2) keeps the previous key valid for a grace period (default 24 hours, config :converger, :api_key_rotation_grace_period in seconds) through previous_api_key_hash and previous_api_key_expires_at, so clients can be switched without downtime. Lookups hash the presented key and compare digests in the database; inactive tenants are rejected with 401.

Scoped keys (api_keys, ADR-0061) have the same format and are stored the same way (SHA-256 key_hash, display prefix). Each has a subset of the scopes read, write and manage, an optional expires_at, and a revoked_at once revoked (revocation is immediate; the row stays so audit entries resolve). TenantAuth requires a scope for every action, read for GET and write otherwise unless the controller says otherwise, and refuses a missing one with 403 insufficient_scope; the primary key and the deprecated channel token have every scope. A key can only issue keys with scopes it has. last_used_at is updated at most once a minute. See the management API.

Admin API token​

/api/v1/admin (tenants, API key issuance) authenticates with ADMIN_API_TOKEN as a bearer token: at least 32 characters, compared as SHA-256 digests in constant time, and only from addresses in ADMIN_IP_WHITELIST. Unset, the admin API answers 401 to everything. Tenant credentials never reach it. Audit entries record actor admin_api.

Channel secrets​

Each channel has a random secret (generated if none is given). It authenticates the channel integration on POST /api/v1/converger/tokens/generate, signs outbound webhooks and verifies inbound signatures. It is stored encrypted; a SHA-256 digest in channels.secret_hash (unique index) is used for the lookup, followed by a constant-time comparison with the decrypted value (Converger.Channels.get_channel_by_secret/1).

Tokens (JWT)​

All tokens are HS256 JWTs created with Joken (Converger.Auth.Signer). Converger tokens are signed with the dedicated keys of TOKEN_SIGNING_KEYS and carry the key's kid in the header, so the keys rotate without downtime and independently of SECRET_KEY_BASE (client tokens). The legacy tokens are signed with SECRET_KEY_BASE.

TokenModuleClaimsLifetimeIssued by
Converger tokenConverger.Auth.ConvergerTokentype: "converger", channel_id, tenant_id, sub, jti, fam (family), optional conversation_id, user_id, scope (only "channel", never combined with conversation_id) and perms (read, write, typing)1800 sPOST /api/v1/converger/tokens/generate (channel secret), POST /api/v1/converger/tokens/refresh (valid token, rotated)
Conversation token (legacy, deprecated)Converger.Auth.Tokenconversation_id, tenant_id, sub3600 sPOST /api/v1/tokens
Channel token (legacy, deprecated)Converger.Auth.Token.generate_channel_token/1channel_id, tenant_id, sub: "channel_<id>"3600 sShown on the admin channel page (/admin/channels)

Because the legacy tokens and keyless Converger tokens share the SECRET_KEY_BASE signer, every verifier also checks the token's shape (ADR-0026):

  • x-channel-token (TenantAuth, POST /api/v1/tokens, POST /api/v1/conversations) accepts only channel tokens (Converger.Auth.Token.verify_channel_token/1: channel_id, no conversation_id, no type claim). Conversation tokens and Converger tokens are refused with 401. Before #23 any Converger-signed JWT was accepted there, so a conversation token sent as x-channel-token acted as the whole tenant.
  • The legacy socket /socket accepts only conversation tokens (verify_conversation_token/1).
  • Converger tokens are rejected by both legacy verifiers.

The legacy tokens and the legacy socket are deprecated; see migrating from the legacy surfaces.

Converger tokens are rejected unless type is "converger", and every authenticated request re-checks that the token's channel is still active (403 otherwise) and that the token was not revoked (401 token_revoked), with one ETS lookup per node (ADR-0059):

  • Revocation: POST /api/v1/converger/tokens/revoke revokes a token's family (logout) or, with the channel secret, every token of an end user (a ban). Every node refuses the token within a second and the subject's sockets are disconnected. Deactivating a channel still blocks all of its tokens.
  • Refresh rotation: a refreshed token stays valid for a 30 s grace period, then is refused; refreshing it again after that revokes the whole family (reuse detection). Derived conversation tokens never outlive the token they came from.
  • Permissions: the perms claim (read, write, typing) restricts a token, for example to a read-only viewer; checked by the REST controllers, the Phoenix channel and the Protocol v1 session.

See Client tokens.

WebSocket authentication​

SocketConnect parameterJoin rule
/socket/converger (ConvergerWeb.ConvergerSocket)token: a Converger token; the channel must be active for the token's tenantconverger:conversation:<id>: allowed when the token's conversation_id equals <id>, or, for a scope: "channel" token, when the conversation belongs to the token's tenant and either to the token's channel (owned) or, for a websocket channel, to a channel that an enabled routing rule routes to it (routed). An unscoped channel-level token cannot join. converger:channel:<id>: only a scope: "channel" token of that websocket channel
/socket (ConvergerWeb.UserSocket, legacy, deprecated)token: a conversation token (Converger tokens and channel tokens are refused at connect)conversation:<id>: only the conversation named in the token, and only while its channel is active

The gRPC transport (off unless GRPC_ENABLED, Protocol v1 over gRPC) checks the same Converger token on hello as the native WebSocket, with the same identity and conversation rules; a bad credential ends the stream with UNAUTHENTICATED. Its port speaks plaintext HTTP/2 unless GRPC_TLS_CERTFILE and GRPC_TLS_KEYFILE are set: never expose a plaintext gRPC port to the internet, since the bearer token travels in the metadata. Restrict it with the Helm chart's networkPolicy.grpcFrom. Messages above 1 MiB end the stream before they are buffered.

Sockets get a per-subject id (converger_socket:<tenant_id>:user:<user_id>, ...:conversation:<id>, or ...:channel:<channel_id> for a scope: "channel" token without user_id), so a forced disconnect affects one end user (or one agent console token) only. Channel-level tokens with none of these get no id: their sockets are still counted in presence but cannot be disconnected by id (ConvergerWeb.Sockets, ADR-0020). Browser connections are also checked against CHECK_ORIGIN (default: the PHX_HOST host); clients that send no Origin header are not affected. The wire format is Converger Protocol v1 (spec in progress, #21, #63).

Authorization and tenant isolation​

Converger is multi-tenant in one database, so isolation is enforced in code on every path:

  • The tenant always comes from the credential, never from the request. TenantAuth assigns the tenant from the API key or channel token, Converger tokens carry tenant_id and channel_id, and controllers merge those values into the attributes they persist (for example conversation creation overwrites tenant_id and channel_id from the token).
  • Lookups are scoped. Context functions take the tenant (Conversations.get_conversation/2, Channels.get_active_channel/2); a resource of another tenant is reported as not found. Attachments of another tenant, or of another conversation for a conversation-bound token, return 404 (../storage.md).
  • System fields are not client-settable. Activities use separate client and system changesets, so a client cannot set tenant_id, seq, delivery state and similar fields (ADR-0005).
  • Cross-tenant references are rejected. Routing rules validate that the source and every target channel belong to the same tenant.
  • Listing other users' data requires the API key. GET /api/v1/conversations refuses channel tokens, because a channel token is held by end-user clients.
  • Programmatically set fields are never cast (for example must_change_password, api_key_hash, secret_hash).

Secrets at rest​

Encryption with Cloak​

Converger.Vault is a Cloak vault using Cloak.Ciphers.AES.GCM (AES-256-GCM, 12-byte IV, authenticated). Each configured key gets a cipher tag derived from its SHA-256 fingerprint (AES.GCM.<8 hex chars>), so ciphertexts are self-describing and any configured key can decrypt them.

DataProtection
channels.secretEncrypted (Converger.Encrypted.Binary) + SHA-256 secret_hash for lookup
webhook_subscriptions.secret, webhook_subscriptions.headersEncrypted (Converger.Encrypted.Binary, Converger.Encrypted.Map); the API returns header names only and the secret only on create or rotation
channels.config (provider access tokens, app_secret, verify_token, Infobip api_key, webhook headers, ...)Encrypted as a whole map (Converger.Encrypted.Map)
tenants.api_key_hash, previous_api_key_hashSHA-256 digest only; plaintext never stored
admin_users.password_hash, tenant_users.password_hashbcrypt (PBKDF2-SHA512 in a CONVERGER_PASSWORD_HASHER=pbkdf2 build)
admin_users.totp_secret, tenant_users.totp_secretEncrypted (Converger.Encrypted.Binary)
user_sessions.token_hash, account_tokens.token_hashSHA-256 digest only; the session token lives in the signed cookie, the reset token in the mailed link
Activities, attachments, tenants.alert_webhook_url, webhook subscription URLs, webhook_events payloads, audit logsNot encrypted by the application; rely on database and storage encryption

Sensitive schema fields are also declared with redact: true, so inspect/1 (and therefore crash reports) does not print them.

CLOAK_KEY​

CLOAK_KEY is a base64-encoded 32-byte key, required in production: config/runtime.exs refuses to boot without it, and the vault raises on a key that does not decode to 32 bytes. Generate one with openssl rand -base64 32 or mix run -e 'IO.puts(Converger.Vault.generate_key())'. Development and test use fixed, non-secret keys from config/dev.exs and config/test.exs.

Back the key up separately from the database: a database backup cannot decrypt channel secrets without it (Backups and restore).

Key rotation​

  1. Generate a new key and set it as CLOAK_KEY.

  2. Move the old key into CLOAK_RETIRED_KEYS (comma-separated; still accepted for decryption).

  3. Deploy, then re-encrypt every channel, webhook subscription and TOTP secret with the new key:

    bin/converger eval "Converger.Release.reencrypt_secrets()"
  4. Remove the retired key once the command reports the re-encrypted counts.

If the old key may have leaked, also rotate the channel secrets themselves. Details: ADR-0012.

Audit logs and log redaction​

Administrative changes to tenants (including API key rotation and rate-limit overrides), channels, routing rules, admin users, tenant users, webhook subscriptions and API keys are written to audit_logs (resource types tenant, channel, routing_rule, admin_user, tenant_user, webhook_subscription, api_key). When the change is made with an actor (admin panel, portal, tenant API with the key id as actor_id, admin API as admin_api), the entry is inserted in the same Ecto.Multi transaction as the change, with the actor and a before/after snapshot. Converger.AuditLogs.Changes serializes the structs, drops associations (they are audited as their own resources), and passes the result through Converger.Secrets.redact/1, which replaces values recursively, including inside nested maps such as channels.config:

  • keys access_token, api_key, secret, token, password, password_hash, verify_token, app_secret, authorization, x-api-key, x-channel-token (case-insensitive), and
  • any key ending in _secret, _token, _hash or _password (the broker channels' sasl_password).

Values become "[REDACTED]"; nil stays nil. Audit logs are browsable at /admin/audit_logs.

Production logs are JSON (LoggerJSON on the default handler, see Observability) with LoggerJSON.Redactors.RedactKeys for api_key, secret, token, password, access_token, app_secret, verify_token, x-api-key, x-channel-token and authorization in log metadata.

Inbound request signatures​

Inbound webhooks (POST /api/v1/channels/:channel_id/inbound and /status) are verified against the raw request body, which ConvergerWeb.CacheBodyReader keeps before JSON parsing:

  • Generic scheme: x-converger-signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, keyed with the channel secret. The timestamp must be within 300 seconds of the server clock (config :converger, :inbound_signature_tolerance_seconds). Several v1 values are accepted, for secret rotation.
  • Adapters with a native scheme (WhatsApp Meta X-Hub-Signature-256 with app_secret, Slack X-Slack-Signature with signing_secret and a 5 minute timestamp window) verify that instead.
  • A signature that is present but invalid is always rejected with 401.
  • channels.require_signature (default true for new channels) also rejects missing and legacy sha256=<hex> signatures. Channels that existed before the column was added were backfilled with false; they still accept unsigned or legacy requests and log a DEPRECATED warning for each.

Details and rationale: ADR-0009.

Outbound webhooks: signing and SSRF guard​

Every outbound webhook request carries x-converger-signature in the same format, plus x-converger-event and a stable x-converger-delivery-id for deduplication. Webhook URLs pass through Converger.Channels.UrlGuard when the channel is saved and again before every request: private, loopback, link-local, metadata and other non-public addresses are rejected, every resolved address must pass, and the connection is pinned to the checked address so DNS rebinding cannot redirect it. Redirects are not followed. Operators can allow internal targets with WEBHOOK_ALLOWED_TARGETS (or disable the guard with WEBHOOK_ALLOW_PRIVATE_TARGETS=true, development only).

Verification code, the test vector and the full blocked-range list are in Outbound webhooks; the design is in ADR-0014.

note

The SSRF guard applies to webhook channel deliveries and to platform event webhooks, which include the tenant health-alert URL (tenants.alert_webhook_url, now a managed subscription): targets are checked when saved and again, with the connection pinned, before every request.

Rate limiting and login lockout​

Hammer 7 counters (node-local ETS, optionally replicated across the cluster over PubSub) protect activity creation, uploads, inbound webhooks and token endpoints, and failed logins are counted per client IP and per account (5 failures per minute each by default) with a lockout before the password is checked. Wrong 2FA codes count the same way. Password reset requests are limited per IP and per account. Rejected requests get 429 with Retry-After. See Rate limiting and ADR-0013.

HTTP edge​

ControlImplementationConfiguration
Client IP behind proxiesConvergerWeb.Plugs.TrustedProxies (first plug in the endpoint after the health probes); forwarding headers only from trusted peersTRUSTED_PROXIES, see ../security.md and ADR-0011
Admin network restrictionConvergerWeb.Plugs.AdminAuthADMIN_IP_WHITELIST, see ../security.md
HTTPS redirect + HSTSConvergerWeb.Plugs.ForceSSL (runtime Plug.SSL; X-Forwarded-Proto honoured only from trusted proxies), on by default in productionFORCE_SSL, HSTS, HSTS_MAX_AGE, HSTS_INCLUDE_SUBDOMAINS, HSTS_PRELOAD, FORCE_SSL_EXCLUDE_PATHS, see TLS, HSTS and WebSocket origins
WebSocket origin checkPhoenix check_originCHECK_ORIGIN
CORSCORSPlug, origins read per requestCORS_ORIGINS
MetricsConvergerWeb.Plugs.Metrics serves GET /metrics on the main port; bearer token (constant-time compare) or client-IP allowlist, 404 when neither is configuredMETRICS_TOKEN, METRICS_ALLOWED_IPS; the opt-in PROMETHEUS_PORT listener is unauthenticated, see Observability
Health probesConvergerWeb.Plugs.Health: GET /health/live and /health/ready, unauthenticated by design; responses carry only ok/error per check and short reasons, never error detailsnone
Erlang distribution (clustering)Nodes connect over EPMD (4369) and a dynamic port, authenticated by the cookie; whoever has the cookie and network access can run code on every nodeRELEASE_COOKIE (required when CLUSTER_STRATEGY is set), keep distribution ports on the private network; see Clustering

Design notes for the edge and boot checks: ADR-0022.

Browser headers and uploads​

The :browser pipeline (admin panel and portal) sets Phoenix's secure browser headers (x-content-type-options: nosniff, referrer-policy: strict-origin-when-cross-origin, x-permitted-cross-domain-policies: none) and this Content Security Policy:

content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'

The admin and portal CSS and JavaScript are built into the release and served from /assets, so no third-party origin is allowed (ADR-0060). 'unsafe-inline' remains for the small theme script in the page <head> (it applies the light or dark theme before the first paint) and for the Oban Web dashboard at /admin/oban, which inlines its own script and styles.

Uploaded files are typed by magic bytes (the declared type is ignored), checked against an allowlist, and served only through the authenticated, tenant-scoped download endpoint with nosniff, a sandboxing CSP and a Content-Disposition header; SVG and HTML are served as text/plain. See File storage and attachments.

Fail-fast configuration checks​

The release refuses to boot (config/runtime.exs, Converger.Vault, Converger.Pipeline.Broadway) rather than run insecurely when:

  • DATABASE_URL, SECRET_KEY_BASE or CLOAK_KEY is missing;
  • SECRET_KEY_BASE is shorter than 64 bytes;
  • SECRET_KEY_BASE or CLOAK_KEY matches the SHA-256 fingerprint of a value that was once published in this repository (see Rotating leaked secrets);
  • a Cloak key does not decode to 32 bytes;
  • CHECK_ORIGIN is set but contains no origins;
  • RATE_LIMIT_BACKEND, CLUSTER_STRATEGY, UPLOAD_STORAGE or CDN_TYPE has an unknown value, or a clustering strategy lacks a required option (CLUSTER_SERVICE, CLUSTER_DNS_QUERY);
  • clustering is enabled and RELEASE_COOKIE is not set (rel/env.sh.eex; the cookie baked into the image is known to everyone who has the image);
  • the non-durable Broadway :memory producer is configured in production without allow_memory_producer_in_prod: true.

docker-compose.yml uses ${VAR:?message} for every secret, so docker compose up stops instead of starting with a weak default. No secret is committed; .env is gitignored.

Static analysis and supply chain​

CI (.github/workflows/ci.yml) runs on every pull request:

  • Sobelow (mix sobelow --config, .sobelow-conf, threshold low). Config.HTTPS is ignored because TLS terminates at the proxy, and Config.CSWH because check_origin comes from CHECK_ORIGIN at runtime (a test fails if any config other than dev.exs disables it). Individually accepted findings carry an inline # sobelow_skip [...] with a justification.

  • mix deps.audit (known vulnerabilities) and mix hex.audit (retired packages). Advisories that do not affect Converger are acknowledged in mix.exs under hex: [ignore_advisories: ...], each with the reason and a test that keeps the affected code path unreachable. None of them has a fixed release yet:

    AdvisoryPackageWhy Converger is not affectedEnforced by
    EEF-CVE-2026-95105cloak 1.1.4AES-CTR is unused; the vault only configures AES-GCMtest/converger/vault_test.exs
    EEF-CVE-2026-94206cloak_ecto 1.3.0Cloak.Ecto.PBKDF2 is unusedtest/converger/vault_test.exs
    EEF-CVE-2026-43966cowlib 2.21.0CR/LF in structured-field headers; nothing builds them, and cowboy 2.20 (gRPC server) rejects CR/LF in response headerstest/converger_web/http_advisories_test.exs

    Remove an entry when a fixed version ships; mix hex.audit warns once an entry no longer matches. Bandit 1.13.0 fixed EEF-CVE-2026-105659 and EEF-CVE-2026-105660, so Bandit has no entry; the endpoint still keeps HTTP/2 off (http_2_options: [enabled: false]) because it only needs HTTP/1.1.

  • Credo (--strict), Dialyzer, and compile with warnings as errors.

The Docker workflow builds the image on every pull request and scans it with Trivy (report-only for now, SARIF uploaded to code scanning). Dependabot opens dependency updates. See ADR-0021.

Known limitations​

  • 2FA has no recovery codes and no WebAuthn; a locked-out admin is reset by a super_admin or with Converger.Release.reset_admin_2fa/1 (ADR-0058).
  • The login form reports "Your account has been deactivated" for an inactive account before the password is checked.

Reporting a vulnerability​

Do not open a public issue for security problems. The process is in SECURITY.md:

Include the affected version or commit, the deployment type, reproduction steps and impact. Reports are acknowledged within 3 business days and assessed within 10. Disclosure is agreed with the reporter (90 days by default) and published as a GitHub security advisory. Until releases are tagged, only the latest main receives fixes.

Authorization boundaries​

These rules were tightened after a review of the token and URL handling (see the CHANGELOG):

  • Tenant API credentials. Only the tenant API key and channel tokens unlock the tenant API. All JWTs share one signing key, so TenantAuth checks what a token is: conversation tokens and Converger client tokens carry a tenant_id too, but they belong to end users and are rejected. See API authentication.
  • Channel binding. A Converger client token is bound to its channel: conversations, activities, uploads and attachments of other channels are 404, even in the same tenant. Joining a conversation over the WebSocket needs a conversation-bound token.
  • Tenant ownership is immutable. A routing rule's tenant_id is set on create from the authenticated tenant and cannot be changed by an update.
  • Delivery receipts are scoped. A status webhook updates only deliveries of the channel it was sent to, whether it identifies them by provider_message_id or by delivery_id.
  • SSRF guard everywhere the server makes requests to configured URLs. Webhook targets, the tenant alert_webhook_url and the WhatsApp Infobip base_url are checked when saved and again before each request (DNS can change, and older configurations were saved before the guard existed). Private, loopback, link-local and metadata targets are refused unless explicitly allowed (WEBHOOK_ALLOWED_TARGETS, see webhooks).