Skip to content

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

The 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

  1. Go to Actions in the Zitadel Console
  2. Create a new Action with the code above
  3. Under Flows, select External Authentication
  4. 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 (not restWebhook) because the response body contains the claims to append.
  • The signingKey is used to verify the ZITADEL-Signature header 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)

FieldTypeDescription
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: or urn: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

ActionPermission
Create/update targetsaction.target.write
Create/update executionsaction.execution.write
List targetsaction.target.read
List executionsaction.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:

ScopePurpose
openidRequired for OIDC
profileUser profile info
emailUser email
offline_accessRefresh token (for long-lived access)
User.ReadRead user profile from Graph API
Calendars.ReadRead calendar (if needed)
Mail.ReadRead mail (if needed)

Add scopes in the Zitadel IdP configuration under the Microsoft/Azure IdP settings.


Troubleshooting

Token not appearing in claims

  1. Verify the V1 action is attached to External Authentication → Post Authentication.
  2. Check that ctx.accessToken is populated (only present for external IdP logins, not password logins).
  3. Verify metadata is stored: Zitadel Console → Users → select user → Metadata tab.
  4. Verify the V2 target is reachable and returning 200.
  5. Check the execution is bound to preaccesstoken.

Signature verification failing

  • Ensure you're using the signingKey from 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.