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, and why you should stick to supported flows in production.
Executive summary
I wanted to authenticate users from a server backend without handing tokens to the browser or bouncing people through redirects. That problem shows up in Backend-for-Frontend (BFF) architectures, on IoT devices, and in any application that needs to authenticate without a browser.
While digging around, I found undocumented Zitadel APIs that allow “back-channel” authentication: the server talks directly to the identity provider, keeps credentials off the browser, and skips the redirect dance. It opens a direct line between your application backend and the identity provider that your frontend trusts.
What follows covers when these flows are worth reaching for, how they hold their security properties, and the trade-off between convenience and vendor lock-in. Standard alternatives exist, including Device Flow and CIBA, but they come with limits of their own.
Important: This uses undocumented functionality that may break without notice. Use it to understand the concepts, and build production systems with documented approaches.
The goal is fully browserless authentication that stays simple to wire up while preserving enterprise security guarantees.
When back-channel authentication is needed
A few cases make this worth the trouble:
- Single-page apps with Backend-for-Frontend (BFF) architectures that want token-free frontends
- IoT terminals and kiosks with no browser
- Mobile apps that can’t launch external web views
- Applications that need sub-100ms authentication latency
If you want a supported path, look at Device Flow, CIBA (where it’s available), or JWT-Secured Authorization Requests first. Machine-only workloads should use client-credentials or mTLS tokens. Zitadel’s undocumented APIs give you another option, but they lean on internal behaviour that nobody guarantees.
The flow combines standard OAuth 2.0 (the authorization framework) with OIDC extensions (the identity layer) and a few Zitadel-specific adaptations:
| Step | What Happens | Standard/Custom | Key Point |
|---|---|---|---|
| JWT grant | App proves its identity | RFC 7523 Standard | Like showing an official ID |
| Session creation | User credentials verified | Zitadel-specific | Like getting a visitor badge |
| Authorization | Permission request started | Standard + custom headers | Like asking for building access |
| Token exchange | Final credentials issued | Standard | Like receiving your access card |
The custom parts, session creation and the special headers, are what make this browserless. They’re also what make it unsupported.
What can go wrong without a browser
Authenticating without a browser changes the threat picture. Here is what can go wrong and how this flow defends against each problem.
An attacker might impersonate a legitimate service, the equivalent of a fake employee badge. Cryptographic certificates and private keys defend against that. Someone could tamper with data in transit, altering tokens the way you might forge a document in the post. Digital signatures and encrypted channels handle that one. Tokens can also be intercepted by a party who shouldn’t see them, like reading mail during delivery, which is why the flow relies on end-to-end encryption and keeps token data minimal. And an application could try to exceed its permissions, a visitor badge wandering into restricted areas. Careful scope management and policy enforcement keep that in check.
Comparing standard OIDC security against the back-channel adaptations, four properties matter. Credential isolation holds up, because users still authenticate directly with the identity provider. Token binding is maintained through strong client authentication. Phishing resistance is limited, since it depends on whatever authentication methods the identity provider offers. Consent visibility is the one that suffers: the undocumented flow bypasses it, so users never see explicit permission screens.
Even an experimental approach has to keep the core security properties. This flow preserves most of the OIDC benefits and trades user consent visibility for operational convenience. That trade is the part to be honest about.
Authentication flow state management
The process moves through distinct states, and each one is a security checkpoint.
Each step has to complete before the next one starts, so a half-finished flow can’t issue a token.
Seven steps build a secure path from user credentials to application access with no browser involved: (1) app authentication, (2) user verification, (3) authorization initiation, (4) session binding, (5) token exchange, (6) server-side storage, (7) token validation. These principles apply when you evaluate any authentication solution, not just this undocumented one.
How back-channel authentication actually works
The full back channel flow runs in six steps, each with a specific security job. Picture a secure handoff inside a high-security facility, where every stage verifies a different aspect of identity and authorization.
The complete authentication journey
Here is how an application authenticates a user with no browser at all:
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)
}One 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 you can’t count on that for other client types. The 2-minute expiry follows the usual practice of keeping the replay window short.
Think of service account authentication as a special employee ID card for applications. The 2-minute expiry means a stolen credential is useless within a couple of minutes.
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,
},
},
}An implementation note: if the username lookup fails, you’ll need a second API call for password verification, so test your Zitadel configuration. Passwords pass through application memory and must be cleared securely after use. Reach for memset_s(), SecureZeroMemory(), or explicit_bzero() depending on the platform.
This step creates a verified session by sending user credentials straight to the identity provider, like a receptionist checking ID before handing over a visitor badge. The password travels only from your application to the identity provider, not through a chain of systems.
There is a security consideration here. Your backend handles credentials on their way to the identity provider, which means it needs the same level of trust and protection as the IdP itself. That’s extra attack surface a browser-based flow avoids, because the credentials now live in your application’s memory and on its network path. Put the right controls in place: secure memory handling, encrypted channels, and audit logging.
Step 3: Authorization without browser navigation
The undocumented behaviour 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")A critical warning: the x-zitadel-login-client header triggers unsupported behaviour that may change without notice. Zitadel engineers have discussed it publicly, but it still sits outside their stable API.
This step uses an unofficial back entrance. It works, but nothing guarantees it stays open. It’s the most fragile part of the flow, and the reason production systems should stick to standard approaches.
A note on PKCE: private keys give you strong application authentication, but the security standards still recommend adding PKCE, an extra security layer, for server-based applications. It protects against attacks where a malicious site tricks a user into an unwanted action or intercepts the authentication code. PKCE adds complexity, and it also means the flow keeps working safely if you later adapt it for mobile 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 links the authenticated session to the OAuth flow and stops session hijacking.
Session binding connects your verified identity to one specific authorization request, the way a bank teller ties ID verification to your transaction. An attacker can’t hijack the session or the authorization on its own.
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 work at different layers, and you can combine them for defence in depth.
It’s worth comparing the two. private_key_jwt gives strong authentication, but mutual TLS offers stronger guarantees through channel binding. mTLS authenticates the client and lets the identity provider issue tokens cryptographically bound to that specific certificate, though the client must still present the bound access token on every request.
This is the final handoff, like swapping a coat check ticket for your coat. Your application proves its identity one more time with a private key JWT, showing both ID and a unique signature, and receives the access tokens.
Step 6: Server-side token management
tokens := &OIDCTokens{
AccessToken: tokenResp.AccessToken,
IDToken: tokenResp.IDToken,
RefreshToken: tokenResp.RefreshToken,
}The security payoff: tokens stay out of browser storage and URLs, which removes client-side theft vectors. That holds only if the tokens stay server-side for their whole lifecycle. Forward them to a browser or mobile client and you lose the benefit.
One more thing to flag. This flow bypasses the standard user consent screens. Mitigate it with pre-registered scopes, admin review, and explicit audit trails.
Step 7: Validated token processing
Validate the token even when it comes from a trusted source. Here is what your application checks:
// 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 a trusted source, you confirm the token is genuine, not expired, and meant for your application. That’s what protects you against compromised keys and replay attacks.
The validation steps break down like this. Signature verification confirms nobody tampered with the token. Expiration checks stop old, possibly compromised tokens from being reused. Audience validation confirms the token was meant for your application. Issuer validation confirms it came from the identity provider you trust.
Risk assessment and trade-offs
This approach removes a few attack vectors. Browser redirect manipulation goes away. So does client-side token exposure, as long as the tokens stay server-side. Authorization endpoint CSRF is off the table too.
Some risks stick around. You still have general web vulnerabilities on your application endpoints, and you’re still exposed to TLS-based attacks.
And you take on new risks. You now depend on undocumented APIs with no compatibility guarantees. You lose user consent visibility. You might run into terms of service issues. Your backend becomes a credential handler, which raises the trust and security bar for that component.
Production considerations
For production, weigh these alternatives:
- OAuth Device Flow: standardised and widely supported.
- CIBA: where it’s available, gives you standard back channel authentication.
- Reverse proxy pattern: put authentication at the edge using supported flows.
- Service mesh: hand authentication to the infrastructure layer.
Whichever you choose, plan for migration. Keep a fallback ready in case the undocumented APIs change or break.
Implementation insights
A few things I took away from this exploration.
On credential handling, clear sensitive data the moment you’re done with it:
// Clear sensitive data immediately after use
defer func() {
for i := range passwordBytes {
passwordBytes[i] = 0
}
}()On API versioning, program defensively against undocumented endpoints:
// Defensive programming for undocumented APIs
if resp.StatusCode == http.StatusNotImplemented {
return errors.New("back channel flow not supported")
}On monitoring, log the authentication flow types so you can track migration, alert on API changes or deprecation warnings, and keep metrics on fallback usage.
Conclusion
Poking at Zitadel’s undocumented APIs showed me that OIDC security properties can survive in a browserless flow. The implementation taught me a lot about how authentication actually works, but production systems should prefer standardised approaches like Device Flow or CIBA.
The lesson that stuck is understanding what each security measure is for. Even with undocumented APIs, you still have to hold the line on token validation, secure credential handling, and proper authentication.
If your organisation needs browserless authentication, evaluate the standard alternatives before you commit to a vendor-specific solution. It keeps the system maintainable over time. Custom solutions to authentication problems exist, but standards-based flows are easier to keep working when the vendor changes things.
My recommendation: use this analysis to understand the OIDC security properties, and build production systems on documented, supported authentication flows.
References
- OpenID Connect Core 1.0
- OAuth 2.1 (IETF draft-ietf-oauth-v2-1-10)
- RFC 9449: “OAuth 2.0 Demonstrating Proof of Possession (DPoP)”
- RFC 8705: “OAuth 2.0 Mutual-TLS Client Authentication”
- RFC 9068: “JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens”
- RFC 8693: “OAuth 2.0 Token Exchange”
- ZITADEL GitHub Discussion #7530: x-zitadel-login-client header