Authentication¶
Mailyte has several authentication mechanisms for different access patterns: HTTP API credentials, browser sessions, mailbox SASL passwords, and SMTP API-key credentials.
API Key Authentication¶
API requests carry an X-API-Key header, validated by worker/api/utils/auth.py.
How API Keys Work¶
- A key is generated server-side (
secrets.token_urlsafe(32)) — for example during bootstrap, which returns it exactly once - Only its SHA-256 digest is stored; each request is looked up by the digest of the presented key:
SELECT ... FROM api_keys WHERE key_hash = %s AND active = 1.key_idholds a non-secret display identifier (first 8 characters + row ULID) - If found, active, and not past
expires_at(expired keys get401 API key expired, without a brute-force strike), permissions and organization scope are attached to the request
Hash-based verification since 2026-08-30
Before 2026-08-30, key_id held the raw key and verification matched on it directly, so a database read yielded usable credentials. Verification now uses key_hash only, and migration 0019_api_key_hash_only redacts the raw values from key_id. Until 0019 has run, the auth path keeps a legacy fallback that matches un-migrated rows by key_id and backfills their hash; after 0019, raw keys exist nowhere. Keys issued before the change keep working unchanged. If your database may have been exposed before 0019 ran, rotate keys — the old rows were readable as credentials.
Brute-force protection on the API-key check¶
Invalid-key attempts are rate-limited per source IP: 20 invalid keys per 5 minutes, with exponential backoff from the 5th failure, returning 429 once blocked. The limiter is checked before the key lookup, so a locked-out client gets no free guesses.
Key Properties¶
| Property | Description |
|---|---|
key_id | Non-secret display identifier (key prefix + row ULID) |
key_hash | SHA-256 of the key — the verification value |
permissions | JSON: {"read": true, "write": true}, optionally read_only, admin_access |
organization_id | Scoped to an org; platform-scope credentials have none |
active | Revocation flag — set 0 to kill the key immediately |
Scope and roles (ADR-002)¶
Every route declares a scope, and the two scopes never mix:
organization— tenant credentials. Queries are forcibly filtered to the credential's ownorganization_id.platform— staff. A tenant credential can never reach a platform route regardless of its permission flags. Platform identities (platform_operators) carry a role —support<operator<admin<owner— and routes gate on the minimum role (e.g. monitoring reads needsupport; service restarts needoperator).
Best Practices¶
- Rotate keys regularly — at least every 90 days
- Use org-scoped keys — don't hand out platform scope
- Use separate keys per application or environment
- Never commit keys to version control
- Deactivate unused keys —
active = 0takes effect on the next request
Session Authentication¶
Browser surfaces (dashboard, console, webmail) use session cookies rather than long-lived keys in the client:
| Session | Table | Notes |
|---|---|---|
| Tenant dashboard | web_sessions | Token stored as SHA-256 hash; same downstream context as an API key |
| Platform operators | operator_sessions + platform_operators | Individually revocable staff identities with roles — this replaced the old shared X-Admin-Password admin surface on the API |
| Webmail / mailbox | mailbox_sessions | Login verified against Dovecot over IMAP (not a stored copy of the password), and the session holds the SMTP credential for sending — fixed 2026-08-21 after a stale-password-copy incident. Lifetimes are platform-aware: browsers get 8 h idle / 7 d absolute, native clients that send X-Client-Platform: ios\|android\|macos\|windows\|linux get 30 d / 180 d (env-overridable), and each session's idle window is stored on its own row. A mailbox carrying a temporary password gets a session that can only change the password or sign out (403 password_change_required). Details: Mailbox Authentication |
Passwords for users and mailboxes are bcrypt-hashed (hash_password / verify_password); legacy SHA-256 hashes are rejected and force a reset. Login checks against a dummy bcrypt hash when the account doesn't exist, so response timing doesn't reveal account existence.
Mailbox holders change their own password with POST /api/v1/mailbox/security/password: the current password is re-verified against Dovecot (never against the stored hash), the new one must pass the shared 12-character policy, every other session of the mailbox is revoked, and Dovecot's auth cache is flushed so IMAP/SMTP honour the change at once. Administrators reset with POST /api/v1/mailboxes/email-accounts/{id}/reset-password, optionally as a temporary password that forces the holder to choose their own at next sign-in; an admin reset revokes all of the mailbox's sessions.
SMTP SASL Authentication (mailbox passwords)¶
When users send email through Mailyte (port 587/465), they authenticate via SASL. Postfix hands the check to Dovecot (smtpd_sasl_type = dovecot), which queries MySQL.
How It Works¶
sequenceDiagram
participant Client as Email Client
participant Postfix
participant Dovecot
participant MySQL
Client->>Postfix: EHLO + STARTTLS
Postfix->>Client: 250 AUTH PLAIN LOGIN
Client->>Postfix: AUTH PLAIN (base64 credentials)
Postfix->>Dovecot: Verify credentials (SASL, inet:dovecot:24100)
Dovecot->>MySQL: SELECT email, password FROM email_accounts WHERE email='%u' AND status='active'
MySQL->>Dovecot: bcrypt hash
Dovecot->>Dovecot: Verify (BLF-CRYPT)
Dovecot->>Postfix: Auth result
Postfix->>Client: 235 Authentication successful Password Storage¶
Mailbox passwords are bcrypt hashes ($2b$...); Dovecot's passdb uses default_pass_scheme = BLF-CRYPT. Only status = 'active' accounts can authenticate.
TLS is mandatory for auth¶
- Postfix:
smtpd_tls_auth_only = yes— AUTH is not even offered before TLS - Dovecot:
disable_plaintext_auth = yes,ssl = required
Supported SASL mechanisms: PLAIN and LOGIN (both only ever inside TLS).
The Dovecot auth cache delays revocation¶
Dovecot caches successful authentications for up to 1 hour (auth_cache_ttl = 1 hour; negative results 1 minute). A deleted, suspended, or password-changed mailbox keeps authenticating from cache until the TTL expires or the cache is flushed:
A doveadm HTTP API (port 24180, internal network only, keyed by DOVEADM_API_KEY) exists for programmatic flushes. SMTP-credential mutations use it automatically, and since 2026-08-30 so do the API's mailbox mutations: password change, status change (suspend/deactivate), and delete — both the /email-accounts CRUD routes and the legacy /mailboxes/edit / /mailboxes/delete paths — flush the cache for the affected address, so revocation bites on the next login attempt. The flush is best-effort: if doveadm is unreachable the mutation still succeeds and the change degrades to the cache-TTL window (logged by the API).
The legacy domain cascade delete (POST /api/v1/domains/delete) also flushes each removed mailbox since 2026-08-30. The 1-hour window still applies to changes made any other way: direct SQL edits, migration/restore scripts, or anything else that alters credentials outside the API — flush manually in those cases.
SMTP API-Key Credentials¶
Live since 2026-08-27 (/api/v1/smtp-credentials). Domain-scoped send credentials, distinct from mailbox passwords:
- Generated server-side, returned exactly once; stored only as a bcrypt hash plus a short display prefix
- Username shape:
{domain-slug}-smtp-{random} - Protocol-scoped: a dedicated Dovecot passdb applies only to
protocol smtp— these credentials cannot log in to IMAP/POP3 - May send as any address at their domain (they're wired into
smtpd_sender_login_maps) active,expires_at, andallowed_ipsare enforced inside the Dovecot passdb query itself- Every auth-affecting mutation (revoke, rotate, update, delete) flushes Dovecot's auth cache for that username via the doveadm API — verified live that without the flush a revoked key keeps working for up to an hour
- Per-key usage and event history:
/api/v1/smtp-credentials/{id}/eventsand/{id}/usage AUTO_SUSPEND_ENABLED(automatic suspension of abusive keys bylog_ingestor) exists but is off by default
Authentication Failures¶
API¶
- Missing key:
401 Unauthorized - Invalid key:
401 Unauthorized(and counts toward the per-IP attempt limit) - Too many invalid attempts:
429 Too Many Requests - Insufficient permission/scope/role:
403 Forbidden
SMTP / IMAP¶
- Wrong password:
535 5.7.8(SMTP) /NO [AUTHENTICATIONFAILED](IMAP) - TLS required: AUTH is simply not offered on plaintext connections
Failed SMTP/IMAP auth attempts are recorded in the failed_auth_attempts table and fed to the Dovecot auth-policy server, which applies progressive delays (2s → 32s) and blocks repeat-offender IPs — see Intrusion Detection. Fail2ban is not part of the deployed stack.