Exploring Back Channel Authentication with Zitadel: Understanding OIDC Security Through Undocumented APIs

Analysis of browserless OIDC authentication flows using Zitadel's undocumented APIs, examining security properties and production considerations for enterprise authentication systems.

Exploring Back Channel Authentication with Zitadel: Understanding OIDC Security Through Undocumented APIs

Security is everyone’s job. I’m a technology leader, not a specialist, and I learn by building and working with domain experts. Here’s what I found when I poked at Zitadel’s undocumented back-channel APIs—plus why you should stick to supported flows in production.

Executive Summary

The Problem: Modern web applications with server backends need to authenticate users without exposing tokens to browsers or disrupting user experience with redirects. This challenge affects Backend-for-Frontend (BFF) architectures, IoT devices, and any application requiring browserless authentication.

The Solution: I discovered undocumented Zitadel APIs enabling “back-channel” authentication—direct server-to-identity-provider communication that keeps credentials secure while avoiding browser involvement. This creates a secure direct line between your application backend and identity provider that your application frontend trusts.

Key Insights: Understanding when back-channel flows are needed, how they maintain security, and the trade-offs between convenience and vendor dependency. Standard alternatives like Device Flow and CIBA exist but have limitations.

⚠️ Important: This uses undocumented functionality that may break without notice. Use for understanding concepts, implement production systems with documented approaches.

Goal: Enable fully browserless authentication and simplified application authentication while preserving enterprise-level security guarantees.

When Back-Channel Authentication Is Needed

Primary Use Cases:

  • Single-page apps with Backend-for-Frontend (BFF) architectures requiring token-free frontends
  • IoT terminals and kiosks without browser capabilities
  • Mobile apps that can’t launch external web views
  • Applications requiring sub-100ms authentication latency

Standard Alternatives: Device Flow, CIBA (where supported), or JWT-Secured Authorization Requests. Machine-only workloads should use client-credentials or mTLS tokens.

Zitadel’s undocumented APIs provide an alternative, but rely on unsupported internal behavior.

Understanding the Protocol Layers: This flow combines standard OAuth 2.0 (the authorization framework) with OIDC extensions (the identity layer) plus Zitadel-specific adaptations:

StepWhat HappensStandard/CustomKey Point
JWT grantApp proves its identityRFC 7523 StandardLike showing an official ID
Session creationUser credentials verifiedZitadel-specificLike getting a visitor badge
AuthorizationPermission request startedStandard + custom headersLike asking for building access
Token exchangeFinal credentials issuedStandardLike receiving your access card

The custom parts (session creation, special headers) are what make this browserless, but they’re also what make it unsupported.

Security Challenges and How Back-Channel Authentication Addresses Them

When applications authenticate without browser involvement, they face unique security challenges. Here’s what could go wrong and how this flow defends against each threat:

Impersonation Attacks (eg fake employee badge): Malicious applications pretending to be legitimate services → Defended by cryptographic certificates and private keys

Data Tampering (eg altering documents in transit): Attackers modifying tokens during transmission → Defended by digital signatures and encrypted channels

Information Exposure (eg reading mail during delivery): Token interception by unauthorized parties → Defended by end-to-end encryption and minimal token data

Privilege Escalation (eg visitor badge accessing restricted areas): Applications exceeding their permissions → Defended by careful scope management and policy enforcement

Standard OIDC Security vs. Back-Channel Adaptations:

  • Credential Isolation: Users still authenticate directly with identity provider
  • Token Binding: Maintained through strong client authentication
  • Phishing Resistance: Limited (depends on identity provider’s authentication methods)
  • Consent Visibility: Bypassed in undocumented flow (users don’t see explicit permission screens)

💡 Key Insight: Even experimental approaches must maintain core security properties. The undocumented flow preserves most OIDC security benefits while trading off user consent visibility for operational convenience.

Authentication Flow State Management

The authentication process progresses through distinct states, each representing a security checkpoint:

graph LR A[1. App Authentication
Prove app is legitimate] --> B[2. User Verification
Check credentials with IdP] B --> C[3. Authorization Initiation
Start permission request] C --> D[4. Session Binding
Link user to request] D --> E[5. Token Exchange
Get final credentials] E --> F[6. Server-side Storage
Keep tokens secure] F --> G[7. Token Validation
Verify before trusting]

This state progression ensures each step completes successfully before proceeding, maintaining security integrity throughout the flow.

The Complete Flow: Seven steps create a secure path from user credentials to application access without browser involvement: (1) App authentication, (2) User verification, (3) Authorization initiation, (4) Session binding, (5) Token exchange, (6) Server-side storage, (7) Token validation. These security principles apply to evaluating any authentication solution, not just this undocumented approach.

How Back-Channel Authentication Actually Works

The complete back channel authentication flow involves six distinct steps, each serving a specific security purpose. Think of this like a secure handoff process in a high-security facility—each step verifies a different aspect of identity and authorization.

The Complete Authentication Journey

Here’s how an application securely authenticates a user without any browser involvement:

sequenceDiagram actor Backend participant ZITADEL Backend->>ZITADEL: 1. JWT-profile grant
(POST /oauth/v2/token)
→ serviceAccessToken Backend->>ZITADEL: 2. Create session
(POST /v2/sessions) ZITADEL-->>Backend: sessionId + sessionToken
status = VERIFIED Backend->>ZITADEL: 3. /authorize
(GET … x-zitadel-login-client + Bearer) ZITADEL-->>Backend: 302 /login?authRequest=V2_xxx Backend->>ZITADEL: 4. Bind session
(POST /v2/oidc/auth_requests/V2_xxx) ZITADEL-->>Backend: callbackUrl ?code=AUTH_CODE Backend->>ZITADEL: 5. /token
(authorization_code + private_key_jwt) ZITADEL-->>Backend: access_token, id_token Backend-->>Backend: 6. parse roles (token / userinfo)

Step 1: Service Account Authentication

Service accounts authenticate using JWT Bearer grants per RFC 7523:

func (sa *ServiceAccount) generateClientAssertion() (string, error) {
    now := time.Now().UTC()
    claims := jwt.RegisteredClaims{
        Issuer:    sa.clientID,  // Must be OAuth client_id
        Subject:   sa.clientID,  // Must match issuer for client auth
        Audience:  jwt.ClaimStrings{tokenEndpoint},
        ExpiresAt: jwt.NewNumericDate(now.Add(2 * time.Minute)), // Tighter window
        IssuedAt:  jwt.NewNumericDate(now),
    }

    token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
    token.Header["kid"] = sa.keyID

    return token.SignedString(sa.privateKey)
}

Technical note: RFC 7523 requires iss and sub to equal the OAuth client_id. For Zitadel service users, this happens to match the user ID, but this isn’t guaranteed for other client types. The 2-minute expiry follows security best practices for minimising replay windows.

Service account authentication acts like a special employee ID card for applications, with 2-minute expiry preventing misuse if credentials are stolen.

Step 2: Session Creation with Credentials

Zitadel’s Session API accepts combined checks:

createReq := map[string]interface{}{
    "checks": map[string]interface{}{
        "user": map[string]string{
            "loginName": loginName,
        },
        "password": map[string]string{
            "password": password,
        },
    },
}

Implementation note: Username lookup failure means you’ll need a second API call for password verification. Test your Zitadel configuration. Passwords pass through application memory and must be securely cleared after use (use memset_s(), SecureZeroMemory(), or explicit_bzero() depending on platform).

This step creates a verified session by sending user credentials directly to the identity provider—like a receptionist checking ID before issuing a visitor badge. The password travels only from your application to the identity provider, not through multiple systems.

Security consideration: Your backend server handles credentials in transit to the identity provider, requiring the same level of trust and security as the IdP itself. This introduces additional attack surface that standard browser-based flows avoid—credentials now exist in your application’s memory and network path. Implement appropriate security controls including secure memory handling, encrypted channels, and audit logging.

Step 3: Authorization Without Browser Navigation

The undocumented behavior uses Zitadel-specific headers:

headers := map[string]string{
    "x-zitadel-login-client": serviceUserID,  // Undocumented header
    "Authorization":          "Bearer " + serviceAccessToken,
}

// Standard OAuth parameters
params.Set("response_type", "code")
params.Set("scope", "openid profile email")

Critical warning: The x-zitadel-login-client header triggers unsupported behaviour that may change without notice. While publicly discussed by Zitadel engineers, this remains outside their stable API.

This step uses an unofficial back entrance (undocumented headers) that works but isn’t guaranteed to stay available. It’s the most fragile part of the flow—why production systems should use standard approaches.

PKCE consideration: Although using private keys provides strong application authentication, security standards still recommend adding PKCE (an extra security layer) for server-based applications to protect against attacks where malicious websites trick users into unwanted actions or intercept authentication codes. Adding PKCE makes the implementation more complex but ensures the flow works safely if you later adapt it for mobile apps or browser-based applications.

Step 4: Session Binding

Binding the authenticated session to the authorization request:

bindRequest := struct {
    Session struct {
        ID    string `json:"sessionId"`
        Token string `json:"sessionToken"`
    } `json:"session"`
}{}

This step links the authenticated session to the OAuth flow, preventing session hijacking.

Session binding connects your verified identity to the specific authorization request—like a bank teller linking ID verification to your transaction. This prevents attackers from hijacking the session or authorization separately.

Step 5: Token Exchange with Strong Authentication

The token exchange uses private_key_jwt:

data.Set("grant_type", "authorization_code")
data.Set("code", authCode)
data.Set("client_assertion", assertion)
data.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")

mTLS authenticates the TLS channel; private-key JWT authenticates the client. They operate at different layers and can be combined for defense-in-depth.

Security comparison: While private_key_jwt provides strong authentication, mutual TLS offers stronger guarantees through channel binding. mTLS both authenticates the client and allows the identity provider to issue tokens that are cryptographically bound to that specific certificate, but the client must still present the bound access token on every request.

The final handoff—like exchanging a coat check ticket for your item. Your application proves its identity once more with a private key JWT (showing both ID and unique signature) to receive the access tokens.

Step 6: Server-Side Token Management

tokens := &OIDCTokens{
    AccessToken:  tokenResp.AccessToken,
    IDToken:      tokenResp.IDToken,
    RefreshToken: tokenResp.RefreshToken,
}

Security property: Tokens avoid browser storage and URLs, eliminating client-side theft vectors — assuming tokens remain server-side throughout their lifecycle. If tokens are forwarded to browser or mobile clients, this benefit is lost.

Important: This flow bypasses standard user consent screens. Mitigate with pre-registered scopes, admin review, and explicit audit trails.

Step 7: Validated Token Processing

Token validation is mandatory even from trusted sources. Here’s what your application needs to verify:

// Essential validation steps - never skip these!
func validateToken(tokenString string) error {
    // 1. Verify the digital signature
    // 2. Check expiration time
    // 3. Validate the intended audience
    // 4. Confirm the issuer is trusted
    // 5. Extract user roles and permissions
    return validateAllCriticalClaims(token)
}

Token validation is like customs checking a passport—even from trusted sources, verify the token is legitimate, not expired, and intended for your application to protect against compromised keys and replay attacks.

Key validation steps:

  • Signature verification: Ensures the token hasn’t been tampered with
  • Expiration checks: Prevents use of old, potentially compromised tokens
  • Audience validation: Confirms the token was intended for your application
  • Issuer validation: Verifies the token came from your trusted identity provider

Risk Assessment and Trade-offs

Eliminated Attack Vectors:

  • Browser redirect manipulation
  • Client-side token exposure (if tokens stay server-side)
  • Authorization endpoint CSRF

Persistent Vulnerabilities:

  • General web vulnerabilities on application endpoints
  • TLS-based attacks

New Risks: Dependency on undocumented APIs, no compatibility guarantees, loss of user consent visibility, potential terms of service violations, backend server becomes credential handler requiring additional trust and security measures.

Production Considerations

For production systems, consider these alternatives:

  1. OAuth Device Flow: Standardised and widely supported
  2. CIBA: Where available, provides standard back channel authentication
  3. Reverse Proxy Pattern: Place authentication at the edge using supported flows
  4. Service Mesh: Delegate authentication to infrastructure layer

Migration Planning: Have fallback strategies ready if undocumented APIs change or break.

Implementation Insights

Key learnings from this exploration:

Credential Handling:

// Clear sensitive data immediately after use
defer func() {
    for i := range passwordBytes {
        passwordBytes[i] = 0
    }
}()

API Versioning:

// Defensive programming for undocumented APIs
if resp.StatusCode == http.StatusNotImplemented {
    return errors.New("back channel flow not supported")
}

Monitoring Requirements:

  • Log authentication flow types for migration tracking
  • Alert on API changes or deprecation warnings
  • Maintain metrics on fallback usage

Conclusion

This exploration of Zitadel’s undocumented APIs reveals how OIDC security properties can be maintained in browserless flows. While the implementation provides valuable insights into authentication mechanics, production systems should prefer standardised approaches like Device Flow or CIBA.

The key learning is understanding each security measure’s purpose. Even when using undocumented APIs, maintaining security properties like token validation, secure credential handling, and proper authentication remains essential.

For organisations requiring browserless authentication, evaluating standard alternatives before adopting vendor-specific solutions ensures long-term maintainability. This analysis demonstrates that while technical solutions exist for authentication challenges, sustainable architectures prioritise standards compliance and vendor-supported approaches.

Final recommendation: Use this analysis to understand OIDC security properties, but implement production systems with documented, supported authentication flows.

References

  1. OpenID Connect Core 1.0
  2. OAuth 2.1 (IETF draft-ietf-oauth-v2-1-10)
  3. RFC 9449: “OAuth 2.0 Demonstrating Proof of Possession (DPoP)”
  4. RFC 8705: “OAuth 2.0 Mutual-TLS Client Authentication”
  5. RFC 9068: “JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens”
  6. RFC 8693: “OAuth 2.0 Token Exchange”
  7. ZITADEL GitHub Discussion #7530: x-zitadel-login-client header