Skip to content

Authentication

All endpoints except GET /health require authentication via Bearer token.

http
Authorization: Bearer <token>

Mailman supports two authentication mechanisms, checked in order:

API Keys

Static API keys configured on the server. Primarily for backward compatibility and internal tooling.

Customer-Signed JWTs

Customers generate JWT tokens signed with their own private key and register the corresponding public key with Mailman.

Supported Algorithms

AlgorithmType
ES256ECDSA (P-256)
ES384ECDSA (P-384)
RS256RSA (2048-bit minimum)

Token Claims

json
{
  "iss": "org-id",
  "sub": "user-or-service-id",
  "iat": 1705312200,
  "exp": 1705315800,
  "inboxes": ["inbox-uuid-1", "inbox-uuid-2"],
  "scopes": ["messages:send", "messages:read"]
}
ClaimRequiredDescription
issYesOrganization identifier (must match registered key)
subYesSubject (user or service identity, for audit logging)
iatYesIssued-at timestamp
expYesExpiration timestamp
inboxesNoRestrict token to specific inbox UUIDs. If omitted, token has access to all inboxes.
scopesNoPermission scopes. If omitted, token has all permissions.

Scopes and inbox bindings are independent lists. A token with scopes: ["messages:send", "threads:read"] and inboxes: ["uuid-1"] can send messages from inbox uuid-1 and read threads in inbox uuid-1, but cannot manage webhooks or access other inboxes.

Scopes

ScopeGrants
messages:sendPOST /send
messages:readGET /messages/{id}
threads:readGET /inboxes/{id}/threads, GET /inboxes/{id}/threads/{id}
threads:deleteDELETE /inboxes/{id}/threads/{id}
webhooks:managePOST /webhooks, GET /webhooks, DELETE /webhooks/{id}
attachments:readGET /attachments/{id}
domains:managePOST /domains, GET /domains, PUT /domains/{id}, DELETE /domains/{id}
inboxes:managePOST /inboxes, GET /inboxes, PUT /inboxes/{id}, DELETE /inboxes/{id}

Validation Flow

On each request:

  1. Extract Authorization: Bearer <token> header
  2. Decode JWT header to determine algorithm
  3. Try all active public keys matching the algorithm until one verifies the signature
  4. Validate exp (reject if expired)
  5. Validate iss (must match the key's organization)
  6. Extract scopes and check against the requested endpoint
  7. Extract inboxes and enforce resource binding (if non-empty, requested inbox must be in the list)
ConditionHTTP Status
Missing/malformed token401
Signature verification failed401
Token expired401
Insufficient scope403
Inbox not in token's inboxes list403

Key Management

Register and manage public keys via the auth keys API:

MethodPathDescription
POST/auth/keysRegister a public key (PEM format)
GET/auth/keysList active keys (PEM not returned)
DELETE/auth/keys/{id}Revoke a key (sets revoked_at)

Register Key

http
POST /auth/keys
Content-Type: application/json

{
  "name": "production-signing-key",
  "algorithm": "ES256",
  "public_key_pem": "-----BEGIN PUBLIC KEY-----\n..."
}

Response: 201 Created

json
{
  "id": "key-uuid",
  "organization_id": "org-id",
  "name": "production-signing-key",
  "algorithm": "ES256",
  "created_at": "2024-01-15T10:00:00Z"
}

Key Rotation

Multiple keys can be active simultaneously. To rotate:

  1. Register the new public key (POST /auth/keys)
  2. Switch token issuance to the new private key
  3. Wait for old tokens to expire
  4. Delete the old public key (DELETE /auth/keys/:id)

Bootstrap

Initial key registration requires a bootstrap mechanism since the API itself requires authentication:

  1. Environment variableMAILMAN_AUTH__BOOTSTRAP_KEY accepts a PEM public key for initial access. Tokens signed with this key have full access (all scopes, all inboxes). Remove the env var after registering a permanent key via the API.
  2. Migration seed — Insert the first key directly into the auth_keys table during initial deployment.

Rate Limiting

Two layers of rate limiting protect the API:

LayerScopeDefaultMechanism
Request ratePer API key / JWT100 req/minIn-memory token bucket (governor)
Volume ratePer API key / JWTConfigurableRedis-backed recipients/minute counter

When rate limited, the API returns 429 Too Many Requests with a Retry-After header.