Matrix: M06M35
Open in panel — /app/access
Operators

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.

Matrix refs: M06 (AuthN/AuthZ)   M35 (MFA Enforcement)

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 session

Required 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: PostMTA maps OIDC groups to internal roles. The IdP must include a 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: PostMTA requires 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.

Store backup codes now: Backup codes are displayed only at enrollment time. PostMTA cannot recover or redisplay them. If you lose your TOTP device and have no backup codes, contact your account administrator to perform an MFA reset. This action is itself logged in the audit log.

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.
AAL2 enforcement date: AAL2 enforcement for the operations listed above was enabled on 2026-04-01. Sessions authenticated before that date were grandfathered at AAL1. All sessions created after 2026-04-01 are subject to AAL2 requirements.

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:

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 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:

PMH-SEC-035: Non-compliant accounts are automatically suspended after a 24-hour grace period following the enforcement date. Suspended accounts can be reinstated only after MFA is enrolled and verified by the account holder.