Identity & MFA
PostMTA supports three authentication mechanisms for operator login: OIDC (OAuth 2.0 / OpenID Connect), SAML 2.0, and local email/password with optional TOTP MFA. All sessions use short-lived RS256 JWTs with refresh token rotation. Certain sensitive operations require AAL2 — Multi-Factor Authentication — per NIST SP 800-63B.
OIDC Authorization Code Flow
PostMTA acts as a SAML SP-equivalent relying party in an OIDC authorization code flow. This is the recommended integration path for organisations using Okta, Azure AD, Google Workspace, or any standards-compliant OIDC provider.
Flow Diagram
1. User clicks "Sign in with OIDC" in PostMTA dashboard
2. PostMTA redirects browser to:
https://auth.example.com/authorize
?response_type=code
&scope=openid%20email%20profile
&client_id=pmta_oidc_client
&redirect_uri=https%3A%2F%2Fapp.postmta.com%2Fauth%2Fcallback
&state=_random_csrf_token
&nonce=unique_nonce_value
3. OIDC Provider authenticates user (may prompt MFA)
4. OIDC Provider redirects to:
https://app.postmta.com/auth/callback?code=AUTH_CODE&state=_random_csrf_token
5. PostMTA exchanges code for tokens:
POST https://auth.example.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https%3A%2F%2Fapp.postmta.com%2Fauth%2Fcallback
&client_id=pmta_oidc_client
&client_secret=pmta_oidc_secret_...
6. PostMTA receives ID token (JWT) + Access token
7. PostMTA validates ID token, extracts claims, provisions sessionRequired Claims
PostMTA validates the ID token according to OIDC ID Token validation rules and requires the following claims:
{
"iss": "https://auth.example.com",
"sub": "user_01HX9P7N3M4Q6R8T2V4W6Y8",
"aud": ["pmta_oidc_client"],
"exp": 1752862860,
"iat": 1752859260,
"nonce": "unique_nonce_value",
"email": "operator@example.com",
"email_verified": true,
"name": "Jane Operator",
"preferred_username": "jane.operator",
"groups": ["admin", "postmta-operators"],
"amr": ["pwd", "mfa"]
}
Required claims for PostMTA OIDC integration:
+---------------+--------+------------------------------------------+
| Claim | Type | Requirement |
+---------------+--------+------------------------------------------+
| sub | string | REQUIRED. Unique user identifier. |
| email | string | REQUIRED. Primary email address. |
| email_verified| boolean| REQUIRED. Must be true. |
| name | string | REQUIRED. Display name. |
| amr | string[]| REQUIRED when MFA is enforced. Must |
| | | include "mfa" or "totp" or "webauthn". |
| groups | string[]| OPTIONAL. Maps to PostMTA roles. |
| nonce | string | REQUIRED if requested in authorize URL. |
| aud | string[]| Must include registered client_id. |
| exp | number | REQUIRED. Token expiry (must be < 1h). |
+---------------+--------+------------------------------------------+groups claim as a string array. Groups not mapped in PostMTA's role configuration are ignored. Unrecognised groups do not grant access.SAML 2.0
PostMTA supports SAML 2.0 as a Service Provider (SP). Configure your IdP with PostMTA's SP metadata and map the required attributes. PostMTA supports IdP-initiated and SP-initiated SSO.
SP Metadata
Download PostMTA's SAML 2.0 SP metadata from Settings → Identity → SAML → Download Metadata. Share this metadata with your IdP administrator.
urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
Assertion Structure
PostMTA expects the SAML assertion to contain the following attribute statements:
https://auth.example.com/saml
...
operator@example.com
https://app.postmta.com/saml/metadata
urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
operator@example.com
admin
postmta-operators
true
WantAssertionsSigned="true" on the SPSSODescriptor. Unsigned assertions are rejected. The IdP must sign assertions using an RSA-SHA256 key. The signing certificate must be valid and not expired.SAML vs AAL
SAML assertions that include an mfa_verified=true attribute in the attribute statement are treated as AAL2 sessions. If your IdP performs an MFA step (e.g., Duo, RSA, FIDO2) and includes this attribute, operators using that SAML session will be able to perform AAL2-gated operations without a second MFA challenge.
TOTP Setup
For organisations without an OIDC or SAML IdP, PostMTA supports TOTP (Time-based One-Time Password) as a second authentication factor. PostMTA implements TOTP per RFC 6238.
Enrollment Flow
# Step 1: User initiates TOTP enrollment via dashboard or API
POST /v1/auth/mfa/totp/setup
Authorization: Bearer pmta_live_...
# Response:
{
"totp_uri": "otpauth://totp/PostMTA:operator@example.com?secret=JBSWY3DPEHPK3PXP&issuer=PostMTA&algorithm=SHA1&digits=6&period=30",
"secret_base32": "JBSWY3DPEHPK3PXP",
"qr_code_png_base64": "iVBORw0KGgoAAAANSUhEUgAAAMAAAADACAYAA...",
"backup_codes": [
"pmta_bc_01_j8k2m",
"pmta_bc_02_n4p7q",
"pmta_bc_03_r2t9w",
"pmta_bc_04_x5v1z",
"pmta_bc_05_a9c3e",
"pmta_bc_06_f6d8h",
"pmta_bc_07_k3g5j",
"pmta_bc_08_p1m2n",
"pmta_bc_09_t8r6s",
"pmta_bc_10_w4u9x"
]
}
# Step 2: User submits TOTP token to verify setup
POST /v1/auth/mfa/totp/verify
Authorization: Bearer pmta_live_...
Content-Type: application/json
{
"totp_token": "123456",
"setup_token": "setup_tok_01HX..."
}
# Step 3: Store backup codes securely
# Backup codes are shown ONCE. The operator must store them
# in a password manager or secure vault. PostMTA stores
# only the bcrypt hash of each backup code.Backup Codes
When TOTP is enabled, PostMTA generates 10 single-use backup codes. Each code may be used exactly once. After all 10 codes are consumed, PostMTA prompts the operator to generate a new set. Backup codes are shown only once at enrollment time and are stored as bcrypt hashes.
MFA Recovery
If an operator loses access to their TOTP device and has no backup codes remaining, an administrator with the admin role may initiate an MFA reset via:
POST /v1/users/:user_id/mfa/reset
Authorization: Bearer pmta_live_...
{
"reason": "operator_lost_totp_device",
"verified_by": "admin_usr_01HX..."
}
# Response:
{
"user_id": "usr_01HX...",
"mfa_enabled": false,
"backup_codes": [
"pmta_bc_01_newcode...",
...
],
"mfa_reset_by": "admin_usr_01HX...",
"reset_at": "2026-07-18T10:00:00Z"
}
# The operator must re-enroll in MFA on next login.AAL2 (NIST 800-63B) Covered Operations
PostMTA classifies operations into AAL1 (single-factor) and AAL2 (multi-factor) per NIST SP 800-63B. AAL2 requires that the session's mfa_verified claim be true — meaning the user authenticated with a password plus a TOTP token, an OIDC ID token with amr including MFA, or a SAML assertion with mfa_verified=true.
Operations requiring AAL2:
The following operations require AAL2 (MFA-verified) authentication:
+--------------------------------+------------------------------------------+
| Endpoint | Operation |
+--------------------------------+------------------------------------------+
| POST /v1/keys | Create API key |
| DELETE /v1/keys/:id | Revoke API key |
| POST /v1/shaping/override | Manual epoch override |
| POST /v1/subusers | Create subuser |
| DELETE /v1/subusers/:id | Delete subuser |
| PUT /v1/domains/:id/verify | Re-verify sending domain |
| POST /v1/webhooks | Register new webhook endpoint |
| DELETE /v1/webhooks/:id | Delete webhook endpoint |
| POST /v1/billing/export | Export billing data |
| PUT /v1/access/policies | Update RBAC policy |
| POST /v1/auth/mfa/disable | Disable MFA |
| POST /v1/audit/export | Export audit log |
| POST /v1/sending-domains | Add new sending domain |
| DELETE /v1/sending-domains/:id| Delete sending domain |
+--------------------------------+------------------------------------------+
Note: All read operations (GET) require only AAL1 (password or OIDC session).
SAML SP-initiated SSO sessions are considered AAL1 unless the IdP
authenticates an MFA step as part of the assertion.JWT Session Structure
PostMTA issues two types of JWT: an access token (15-minute TTL) and a refresh token (30-day TTL with rotation).
Access Token
Access tokens are signed with RS256 (RSA + SHA-256). The public key is available at /.well-known/jwks.json. Clients should cache the JWKS and refresh it every 24 hours or on key rotation.
// Access token (15-minute TTL)
{
"header": {
"alg": "RS256",
"typ": "JWT",
"kid": "2026-07-key-01"
},
"payload": {
"sub": "usr_01HX9P7N3M4Q6R8T2V4W6Y8",
"email": "operator@example.com",
"roles": ["admin"],
"mfa_verified": true,
"auth_method": "oidc",
"iat": 1752859260,
"exp": 1752860160,
"jti": "acc_tok_01HX..."
}
}
// Signature: RS256(RSASHA256(header + "." + payload), private_key)
// Refresh token (30-day TTL, rotated on use)
{
"header": { "alg": "RS256", "typ": "JWT", "kid": "2026-07-key-01" },
"payload": {
"sub": "usr_01HX9P7N3M4Q6R8T2V4W6Y8",
"type": "refresh",
"iat": 1752859260,
"exp": 1753117860,
"jti": "ref_tok_01HX...",
"previous_jti": null
}
}
// Refresh rotation: every refresh token use invalidates itself
// and issues a new refresh token with previous_jti set to the
// consumed token's jti. This enables detection of token theft
// via the "token family" lineage.Refresh Token Rotation
Refresh tokens are single-use and rotate on every use. When a refresh token is used, it is immediately invalidated and a new refresh token is issued with previous_jti set to the consumed token's jti. This forms a chain that enables detection of stolen refresh tokens:
- If a stolen token is used before the legitimate owner uses their current token, both tokens in the chain are invalidated and the operator is prompted to re-authenticate.
- If the legitimate owner uses the current token first, the stolen token becomes useless.
Token Revocation
Operators may revoke all sessions (sign out everywhere) via the dashboard or API:
DELETE /v1/auth/sessions
Authorization: Bearer pmta_live_...
# Revokes all refresh tokens for the authenticated user.
# Next access token attempt will fail with 401.
# Operator must re-authenticate.LDAP / Active Directory Bind with TLS
For enterprises with existing LDAP or Active Directory infrastructure, PostMTA supports LDAP bind authentication for operators (not for API keys). This is typically deployed in combination with SAML or OIDC as a bridge to legacy IdPs.
Requirements
- LDAP server must support StartTLS or LDAPS on port 636.
- TLS minimum version: TLSv1.2.
- Valid CA-signed certificate on the LDAP server (self-signed not supported).
- Bind DN format:
CN=Username,OU=Users,DC=example,DC=comor equivalent for your AD schema.
LDAP Bind Implementation
# LDAP/AD bind with StartTLS
const ldap = require('ldapjs');
async function bindWithTLS(userDN, password) {
const client = ldap.createClient({
url: 'ldaps://ldap.example.com:636',
tlsOptions: {
ca: [fs.readFileSync('/etc/ssl/certs/ca-cert.pem')],
minVersion: 'TLSv1.2',
rejectUnauthorized: true
}
});
await new Promise((resolve, reject) => {
client.bind(userDN, password, (err) => {
if (err) reject(err);
else resolve();
});
});
// Search for user groups
const searchOpts = {
filter: '(member=' + userDN + ')',
scope: 'sub',
attributes: ['cn', 'memberOf']
};
const entries = await client.promiseSearch(
'ou=groups,dc=example,dc=com', searchOpts
);
client.unbind();
return entries.map(e => e.cn);
}
// Group-to-PostMTA role mapping (configured in dashboard)
const roleMapping = {
'cn=postmta-admins,ou=groups,dc=example,dc=com': 'admin',
'cn=postmta-operators,ou=groups,dc=example,dc=com': 'operator',
'cn=postmta-readonly,ou=groups,dc=example,dc=com': 'viewer'
};Group-to-Role Mapping
LDAP group membership is mapped to PostMTA roles via the dashboard (Settings → Identity → LDAP → Role Mapping). Groups not listed in the mapping are ignored. The mapping supports exact DN matching.
PMH-SEC-035 Compliance
PMH-SEC-035 requires that all operator accounts used for production operations be enrolled in MFA. The following rules apply:
- All accounts with the
adminoroperatorrole must have MFA enabled. - Accounts without MFA cannot perform AAL2-gated operations.
- MFA methods accepted: TOTP (RFC 6238), WebAuthn (FIDO2), or IdP-managed MFA via OIDC/SAML (evidenced by
amr/mfa_verifiedclaims). - MFA enrollment is audited (event type
mfa_enabledin the audit log). - MFA bypass is prohibited for admin-role accounts under all circumstances, including support escalations.