Skip to main content

mTLS Gateway Authentication Flow

Detailed architecture diagrams for mTLS certificate-bound token validation in the STOA Gateway (Rust).

Related ADRs: ADR-027 (X509 Headers), ADR-028 (RFC 8705 Binding), ADR-029 (Certificate Lifecycle), ADR-039 (Rust Gateway mTLS)

Linear: CAB-864


1. End-to-End mTLS Flow​

                                                    STOA Platform (K8s Cluster)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ β”‚
mTLS β”‚ HTTP + X-SSL-* headers β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” (TLS 1.2/1.3) β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ ─────────────────► β”‚ β”‚ ────────────────► β”‚ β”‚ β”‚
β”‚ Client β”‚ Client cert + β”‚ F5 β”‚ X-SSL-Client-* β”‚ STOA Gateway β”‚ β”‚
β”‚ App β”‚ Bearer token β”‚ BigIP β”‚ + Authorization β”‚ (Rust/axum) β”‚ β”‚
β”‚ β”‚ ◄───────────────── β”‚ β”‚ ◄──────────────── β”‚ β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ Response β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ Response β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚ β”‚
β”‚ β”‚ JWKS fetch β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ Keycloak β”‚ β”‚
β”‚ β”‚ (JWKS + cnf) β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Step-by-Step Detail​

Step 1: TLS Handshake + Client Certificate Presentation
════════════════════════════════════════════════════════

Client F5 BigIP
β”‚ β”‚
│──── ClientHello ──────────────────►│
│◄─── ServerHello + ServerCert ──────│
│◄─── CertificateRequest ────────────│ F5 requests client cert
│──── ClientCertificate ────────────►│ Client sends its X.509 cert
│──── CertificateVerify ────────────►│ Client proves private key ownership
│──── Finished ─────────────────────►│
│◄─── Finished ──────────────────────│
β”‚ β”‚
β”‚ TLS session established β”‚


Step 2: F5 Validates Client Certificate
═══════════════════════════════════════

F5 BigIP (internal validation)
β”‚
β”œβ”€β”€ Verify signature against CA chain
β”‚ Root CA ──► Intermediate CA ──► Client cert
β”‚
β”œβ”€β”€ Check validity period
β”‚ NotBefore <= now <= NotAfter
β”‚
β”œβ”€β”€ Check revocation
β”‚ β”œβ”€β”€ CRL download (if configured)
β”‚ └── OCSP request (if configured)
β”‚
└── Result: SUCCESS or FAILED:<reason>
β”‚
└── FAILED β†’ F5 returns 403 to client, request does NOT reach Gateway


Step 3: F5 Injects Headers and Forwards Request
════════════════════════════════════════════════

F5 ──────────► STOA Gateway
Headers injected:
Authorization: Bearer <jwt> (from client)
X-SSL-Client-Verify: SUCCESS (from F5)
X-SSL-Client-S-DN: CN=acme-consumer,O=Acme Corp (from F5)
X-SSL-Client-I-DN: CN=STOA Platform CA,O=STOA (from F5)
X-SSL-Client-Serial: 0A:1B:2C:3D:4E:5F (from F5)
X-SSL-Client-Fingerprint: a1b2c3d4e5f6... (from F5, SHA-256 hex)
X-SSL-Client-NotAfter: 2027-01-01T00:00:00Z (from F5)


Step 4: Gateway Extracts Certificate Metadata (auth/mtls.rs)
═══════════════════════════════════════════════════════════

extract_certificate_from_headers(&headers, &config)
β”‚
β”œβ”€β”€ Read X-SSL-Client-Verify
β”‚ "SUCCESS" β†’ continue
β”‚ anything else β†’ return Err(MTLS_CERT_INVALID)
β”‚
β”œβ”€β”€ Read X-SSL-Client-Fingerprint
β”‚ normalize: strip colons, lowercase
β”‚ compute base64url: hex β†’ bytes β†’ base64url_encode
β”‚
β”œβ”€β”€ Read X-SSL-Client-S-DN β†’ subject_dn
β”œβ”€β”€ Read X-SSL-Client-I-DN β†’ issuer_dn
β”‚ check allowed_issuers (if configured)
β”œβ”€β”€ Read X-SSL-Client-Serial β†’ serial
β”œβ”€β”€ Read X-SSL-Client-NotAfter β†’ not_after
β”‚ check expiry: not_after > now
β”‚
└── Return Ok(CertificateInfo { ... })
β†’ stored in request.extensions()


Step 5: Gateway Validates JWT + Extracts cnf (existing + claims.rs extension)
════════════════════════════════════════════════════════════════════════════

Existing JWT flow (unchanged):
β”‚
β”œβ”€β”€ Extract Bearer token from Authorization header
β”œβ”€β”€ Decode JWT header β†’ get kid
β”œβ”€β”€ Fetch JWKS from Keycloak (moka cache, 5min TTL)
β”œβ”€β”€ Verify RS256 signature
β”œβ”€β”€ Validate iss, aud, exp, leeway
└── Deserialize Claims struct
β”‚
└── NEW field: claims.cnf: Option<CnfClaim>
β”‚
└── CnfClaim { x5t_s256: Option<String> }
= base64url-encoded SHA-256 of client cert DER


Step 6: Gateway Verifies Certificate-Token Binding (auth/mtls.rs)
════════════════════════════════════════════════════════════════

verify_certificate_binding(&cert_info, &cnf_claim)
β”‚
β”œβ”€β”€ Get cert_info.fingerprint_b64url (computed in Step 4)
β”‚ "obLD1N5..."
β”‚
β”œβ”€β”€ Get cnf_claim.x5t_s256
β”‚ "obLD1N5..."
β”‚
β”œβ”€β”€ Decode both to bytes
β”‚
β”œβ”€β”€ Timing-safe comparison (subtle::ConstantTimeEq)
β”‚ cert_bytes.ct_eq(&token_bytes)
β”‚
β”œβ”€β”€ MATCH β†’ continue to RBAC + handler
β”‚
β”œβ”€β”€ MISMATCH β†’ 403 MTLS_BINDING_MISMATCH
β”‚
β”œβ”€β”€ No cert + mtls_required β†’ 401 MTLS_CERT_REQUIRED
β”‚
└── No cnf + cert present + require_binding β†’ 403 MTLS_BINDING_REQUIRED

2. Consumer Onboarding (Certificate Registration)​

This extends the CAB-1121 Phase 2 consumer onboarding with certificate binding.

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Operator β”‚
β”‚ (Console) β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”‚ POST /api/v1/consumers
β”‚ Body: { external_id, display_name, tenant_id, certificate_pem }
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Control Plane API β”‚
β”‚ β”‚
β”‚ 1. Validate certificate (existing: certificates.py) β”‚
β”‚ β”œβ”€β”€ Parse PEM β†’ X.509 β”‚
β”‚ β”œβ”€β”€ Check expiry (warn if < 30 days) β”‚
β”‚ β”œβ”€β”€ Verify minimum key size (2048 RSA / P-256 EC) β”‚
β”‚ └── Extract: subject, issuer, SAN, key size, fingerprint β”‚
β”‚ β”‚
β”‚ 2. Compute certificate thumbprint for RFC 8705 β”‚
β”‚ β”œβ”€β”€ DER-encode the certificate β”‚
β”‚ β”œβ”€β”€ SHA-256 hash β”‚
β”‚ └── Base64url encode β†’ x5t_s256 β”‚
β”‚ β”‚
β”‚ 3. Create Keycloak client (existing: CAB-1121 Phase 2) β”‚
β”‚ β”œβ”€β”€ Client ID: {tenant}-{consumer_external_id} β”‚
β”‚ β”œβ”€β”€ Grant type: client_credentials β”‚
β”‚ β”œβ”€β”€ Service account: enabled β”‚
β”‚ β”œβ”€β”€ Client attribute: x5t_s256 = <thumbprint> β”‚
β”‚ └── Client attribute: x509.certificate.sha256 = <hex> β”‚
β”‚ (for Keycloak x509 authenticator, ADR-027) β”‚
β”‚ β”‚
β”‚ 4. Configure cnf protocol mapper on client β”‚
β”‚ β”œβ”€β”€ Mapper type: Hardcoded claim β”‚
β”‚ β”œβ”€β”€ Token claim name: cnf β”‚
β”‚ β”œβ”€β”€ Claim JSON type: JSON β”‚
β”‚ β”œβ”€β”€ Value: {"x5t#S256": "<x5t_s256>"} β”‚
β”‚ β”œβ”€β”€ Add to access token: true β”‚
β”‚ └── Add to ID token: false β”‚
β”‚ β”‚
β”‚ 5. Store consumer record β”‚
β”‚ β”œβ”€β”€ consumer_id, tenant_id, external_id β”‚
β”‚ β”œβ”€β”€ keycloak_client_id β”‚
β”‚ β”œβ”€β”€ certificate_fingerprint (SHA-256 hex) β”‚
β”‚ β”œβ”€β”€ certificate_fingerprint_b64url (for binding check) β”‚
β”‚ β”œβ”€β”€ certificate_subject_dn, certificate_not_after β”‚
β”‚ └── mtls_enabled = true β”‚
β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

3. Bulk Onboarding Flow (Phase 3)​

  Operator prepares CSV (max 100 rows):
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ external_id,display_name,tenant_id,certificate_pem β”‚
β”‚ acme-svc-001,Acme Service 1,tenant-acme,-----BEGIN CERT... β”‚
β”‚ acme-svc-002,Acme Service 2,tenant-acme,-----BEGIN CERT... β”‚
β”‚ ... β”‚
β”‚ acme-svc-100,Acme Service 100,tenant-acme,-----BEGIN CERT... β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”‚ POST /api/v1/admin/consumers/bulk
β”‚ Content-Type: multipart/form-data
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Control Plane API β”‚
β”‚ β”‚
β”‚ Per row (sequential, each row is atomic): β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ 1. Validate certificate PEM β”‚ β”‚
β”‚ β”‚ 2. Compute x5t_s256 thumbprint β”‚ β”‚
β”‚ β”‚ 3. Create Keycloak client + cnf protocol mapper β”‚ β”‚
β”‚ β”‚ 4. Store consumer record in DB β”‚ β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ β”‚ On success: { row, status: "success", consumer_id } β”‚ β”‚
β”‚ β”‚ On failure: rollback row, { row, status: "error", ... } β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚
β”‚ All rows processed (failures do not stop batch). β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β–Ό
Response:
{
"total": 100,
"success": 97,
"failed": 3,
"results": [
{ "row": 1, "status": "success", "consumer_id": "...", "client_id": "..." },
{ "row": 42, "status": "error", "error": "certificate expired" },
...
]
}

4. Failure Modes​

#FailureWhereHTTPError CodeRecovery
F1Client cert expiredF5TLS error (no HTTP)N/AClient renews cert with CA
F2Client cert revokedF5TLS error (no HTTP)N/ARe-issue cert, update CRL
F3Unknown CAF5TLS error (no HTTP)N/AImport CA into F5 trust store
F4F5 verify = FAILEDGateway403MTLS_CERT_INVALIDCheck F5 logs for reason
F5No cert headers + mtls requiredGateway401MTLS_CERT_REQUIREDClient must present cert
F6JWT expiredGateway401TOKEN_EXPIREDRefresh token
F7JWT missing cnf claimGateway403MTLS_BINDING_REQUIREDRegister cert on Keycloak client, get new token
F8Thumbprint mismatchGateway403MTLS_BINDING_MISMATCHToken was issued for different cert
F9Cert rotated, old tokenGateway403MTLS_BINDING_MISMATCHGet new token (ADR-029 grace period)
F10Issuer not in allowed listGateway403MTLS_ISSUER_DENIEDAdd issuer to STOA_MTLS_ALLOWED_ISSUERS
F11Header spoofingGateway403MTLS_CERT_INVALIDEnforce K8s NetworkPolicy (ADR-027)
F12Keycloak mapper missingKeycloak403MTLS_BINDING_REQUIREDConfigure cnf protocol mapper on client

5. Certificate Rotation (ADR-029 Integration)​

  Day 0                            Day 0+grace_period
β”‚ β”‚
β–Ό β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Rotate β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Grace expires β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Cert A β”‚ ──────────→ β”‚ Cert A + Cert B β”‚ ──────────────→ β”‚ Cert B β”‚
β”‚ (active) β”‚ β”‚ (both valid) β”‚ β”‚ (active) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

PUT /api/v1/consumers/{id}/certificate
β”‚
β”œβ”€β”€ Validate new certificate
β”œβ”€β”€ Compute new x5t_s256
β”œβ”€β”€ Store old fingerprint as certificate_fingerprint_previous
β”œβ”€β”€ Set previous_cert_expires_at = now + grace_period (default: 24h)
β”œβ”€β”€ Update Keycloak client attribute with new x5t_s256
└── During grace period: Gateway accepts tokens bound to EITHER thumbprint

6. Network Security​

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Kubernetes Cluster β”‚
β”‚ β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” NetworkPolicy β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ F5 BigIP │───(ingress: allow)──►│ STOA Gateway β”‚ β”‚
β”‚ β”‚ (pod) β”‚ X-SSL-* trusted β”‚ (pod) β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” NetworkPolicy β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ Other Pod │───(BLOCKED)────X────►│ STOA Gateway β”‚ β”‚
β”‚ β”‚ β”‚ X-SSL-* stripped β”‚ (pod) β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚
β”‚ Defense in depth: Gateway strips X-SSL-* from non-F5 β”‚
β”‚ sources even if NetworkPolicy is misconfigured. β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

7. Observability​

Prometheus Metrics​

stoa_gateway_mtls_requests_total{status="success|binding_mismatch|cert_invalid|cert_required|binding_required"}
stoa_gateway_mtls_binding_duration_seconds{quantile="0.5|0.9|0.99"}
stoa_gateway_mtls_cert_expiry_days{consumer_id, tenant_id}

Structured Log Fields​

{
"event": "mtls_auth",
"status": "success",
"cert_subject_dn": "CN=acme-consumer,O=Acme Corp,C=FR",
"cert_serial": "0A:1B:2C:3D:4E:5F",
"cert_not_after": "2027-01-01T00:00:00Z",
"binding_match": true,
"user_id": "acme-consumer-001",
"tenant_id": "tenant-acme",
"trace_id": "abc123"
}

Alerts​

AlertConditionSeverity
MtlsBindingMismatchRatebinding_mismatch rate > 0.1/s over 5mWarning
MtlsCertExpiringSooncert_expiry_days < 30Warning
MtlsCertExpiredcert_expiry_days <= 0Critical
MtlsHighFailureRatefailure rate > 5% over 5mCritical