Appearance
Zitadel Actions V2 — Microsoft IdP Access Token Passthrough
This document describes how to retrieve the Microsoft (Entra ID) access token when a user authenticates through Zitadel's Microsoft IdP, so the downstream application (exto-go) can call Microsoft Graph API on behalf of the user.
Problem
When Zitadel brokers Microsoft login, the flow is:
User → exto-go → Zitadel login → "Login with Microsoft" → Microsoft authenticates
→ Microsoft returns MS access token TO ZITADEL → Zitadel issues its own JWT to exto-goThe original Microsoft access token is not forwarded to the application. Zitadel issues its own JWT, and the MS token stays internal to Zitadel.
Solution: Actions V2 (Complement Token Flow)
Use Zitadel Actions V2 to inject the Microsoft access token as a custom claim in the Zitadel-issued JWT.
Architecture
┌─────────────────────────────────────────────────────────┐
│ Step 1: External Authentication (Post Authentication) │
│ V1 Action stores MS access token as user metadata │
└───────────────────────┬─────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Step 2: Complement Token (preaccesstoken) │
│ V2 Target reads metadata, returns custom claim │
└───────────────────────┬─────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Step 3: exto-go receives JWT with │
│ "urn:custom:ms_access_token" claim │
└─────────────────────────────────────────────────────────┘V1 and V2 Actions coexist — use V1 for the external auth flow (storing the token) and V2 for the complement token flow (adding the claim to the JWT).
Step 1: Store the MS Token as User Metadata (V1 Action)
Create a V1 Action on the External Authentication Flow → Post Authentication trigger. This runs after the user successfully authenticates with Microsoft. The ctx.accessToken field contains the Microsoft access token.
Action Code
javascript
/**
* Post Authentication: store the Microsoft access token as user metadata.
* Trigger: External Authentication → Post Authentication
*/
function storeIdpToken(ctx, api) {
if (ctx.accessToken) {
api.v1.user.appendMetadata('microsoft_access_token', ctx.accessToken);
}
// Optionally store the refresh token for long-lived access.
if (ctx.refreshToken) {
api.v1.user.appendMetadata('microsoft_refresh_token', ctx.refreshToken);
}
}Setup in Zitadel Console
- Go to Actions in the Zitadel Console
- Create a new Action with the code above
- Under Flows, select External Authentication
- Add the action to the Post Authentication trigger
Step 2: Create a Complement Token Target (V2 Action)
2a. Deploy the Webhook Endpoint
Deploy an HTTP endpoint that Zitadel will call during token creation. This endpoint reads the stored metadata and returns it as a custom claim.
Go Implementation
go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"net/http"
)
// signingKey is returned by Zitadel when creating the target.
// Used to verify the ZITADEL-Signature header.
var signingKey string
type FunctionRequest struct {
Function string `json:"function"`
User map[string]any `json:"user"`
UserMetadata []Metadata `json:"user_metadata"`
}
type Metadata struct {
Key string `json:"key"`
Value string `json:"value"` // base64-encoded
}
type AppendClaim struct {
Key string `json:"key"`
Value any `json:"value"`
}
type FunctionResponse struct {
AppendClaims []*AppendClaim `json:"append_claims,omitempty"`
}
func complementToken(w http.ResponseWriter, req *http.Request) {
body, err := io.ReadAll(req.Body)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
defer req.Body.Close()
// Verify signature.
if !verifySignature(body, req.Header.Get("ZITADEL-Signature")) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var funcReq FunctionRequest
if err := json.Unmarshal(body, &funcReq); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
var resp FunctionResponse
for _, md := range funcReq.UserMetadata {
if md.Key == "microsoft_access_token" {
decoded, err := base64.StdEncoding.DecodeString(md.Value)
if err != nil {
continue
}
resp.AppendClaims = append(resp.AppendClaims, &AppendClaim{
Key: "urn:custom:ms_access_token",
Value: string(decoded),
})
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func verifySignature(body []byte, signature string) bool {
if signingKey == "" || signature == "" {
return false
}
mac := hmac.New(sha256.New, []byte(signingKey))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
func main() {
http.HandleFunc("/zitadel/complement-token", complementToken)
http.ListenAndServe(":8090", nil)
}2b. Register the Target in Zitadel
bash
curl -X POST 'https://<ZITADEL_DOMAIN>/v2/actions/targets' \
-H 'Authorization: Bearer <SERVICE_ACCOUNT_TOKEN>' \
-H 'Content-Type: application/json' \
-d '{
"name": "complement-token-ms",
"restCall": {
"interruptOnError": true
},
"endpoint": "https://<YOUR_ENDPOINT>/zitadel/complement-token",
"timeout": "10s"
}'Response — save the id and signingKey:
json
{
"id": "<TARGET_ID>",
"signingKey": "<HMAC_SIGNING_KEY>"
}- Use
restCall(notrestWebhook) because the response body contains the claims to append. - The
signingKeyis used to verify theZITADEL-Signatureheader on incoming requests.
2c. Create the Execution
Bind the target to the preaccesstoken function so it runs every time an access token is issued:
bash
# For access tokens (JWT)
curl -X PUT 'https://<ZITADEL_DOMAIN>/v2/actions/executions' \
-H 'Authorization: Bearer <SERVICE_ACCOUNT_TOKEN>' \
-H 'Content-Type: application/json' \
-d '{
"condition": {
"function": {
"name": "preaccesstoken"
}
},
"targets": ["<TARGET_ID>"]
}'To also include the token in the userinfo/id_token endpoint:
bash
# For userinfo / id_token
curl -X PUT 'https://<ZITADEL_DOMAIN>/v2/actions/executions' \
-H 'Authorization: Bearer <SERVICE_ACCOUNT_TOKEN>' \
-H 'Content-Type: application/json' \
-d '{
"condition": {
"function": {
"name": "preuserinfo"
}
},
"targets": ["<TARGET_ID>"]
}'Step 3: Extract the Claim in exto-go
After the user logs in via Zitadel, the JWT will contain the urn:custom:ms_access_token claim. Extract it from the validated token claims in the exto-go middleware:
go
// After JWT validation, extract the MS access token from claims.
msToken, ok := claims["urn:custom:ms_access_token"].(string)
if ok && msToken != "" {
// Use msToken to call Microsoft Graph API
// e.g., GET https://graph.microsoft.com/v1.0/me
}Payload Reference
Request (Zitadel → Target)
When preaccesstoken triggers, Zitadel POSTs this payload:
json
{
"function": "function/preaccesstoken",
"userinfo": {
"sub": "312909075212468632"
},
"user": {
"id": "312909075212468632",
"username": "user@example.com",
"human": {
"first_name": "John",
"last_name": "Doe",
"email": "user@example.com"
}
},
"user_metadata": [
{
"key": "microsoft_access_token",
"value": "base64_encoded_token_value"
}
],
"org": {
"id": "312909075211944344",
"name": "CustomerOrg"
}
}Response (Target → Zitadel)
json
{
"append_claims": [
{
"key": "urn:custom:ms_access_token",
"value": "eyJ0eXAiOiJKV1QiLCJhbG..."
}
]
}Response Format (Full)
| Field | Type | Description |
|---|---|---|
set_user_metadata | [{key, value}] | Set metadata on the user (optional) |
append_claims | [{key, value}] | Add custom claims to the issued token |
append_log_claims | [string] | Add entries to the audit log |
Important Notes
Claim Key Restrictions
- Do not use the prefix
urn:zitadel:iam— these are reserved and will be silently dropped. - Use a custom prefix like
urn:custom:orurn:exto:. - If a claim key already exists on the token, it will not be overwritten (a log entry is added instead).
Token Expiry
- Microsoft access tokens expire after ~1 hour.
- The stored metadata token will go stale. Options:
- Store the refresh token too and have the complement-token endpoint refresh it before returning.
- Accept staleness and let the consuming app handle 401s from Graph API by redirecting the user to re-authenticate.
Permissions Required
| Action | Permission |
|---|---|
| Create/update targets | action.target.write |
| Create/update executions | action.execution.write |
| List targets | action.target.read |
| List executions | action.execution.read |
V1 and V2 Coexistence
- V1 Actions (JavaScript) and V2 Actions (external webhooks) run concurrently.
- V1 is used here for the External Authentication Flow because it provides direct access to
ctx.accessToken. - V2 is used for the Complement Token Flow because V1's complement token is deprecated.
Microsoft OAuth Scopes
Ensure the Microsoft IdP in Zitadel is configured with the scopes needed for your Graph API calls:
| Scope | Purpose |
|---|---|
openid | Required for OIDC |
profile | User profile info |
email | User email |
offline_access | Refresh token (for long-lived access) |
User.Read | Read user profile from Graph API |
Calendars.Read | Read calendar (if needed) |
Mail.Read | Read mail (if needed) |
Add scopes in the Zitadel IdP configuration under the Microsoft/Azure IdP settings.
Troubleshooting
Token not appearing in claims
- Verify the V1 action is attached to External Authentication → Post Authentication.
- Check that
ctx.accessTokenis populated (only present for external IdP logins, not password logins). - Verify metadata is stored: Zitadel Console → Users → select user → Metadata tab.
- Verify the V2 target is reachable and returning 200.
- Check the execution is bound to
preaccesstoken.
Signature verification failing
- Ensure you're using the
signingKeyfrom the target creation response. - The signature is an HMAC-SHA256 hex digest of the raw request body.
Actions not firing
- V2 executions require the target endpoint to be reachable from Zitadel's network.
- For local development, use a tunnel (ngrok, cloudflared) to expose the endpoint.
- Check Zitadel's action execution logs in the Console under Activity → Actions.

