What is Token Enrichment?

Entra External ID token enrichment is a process where additional claims, attributes, or context are added to authentication tokens (ID Token, Access Token or both) during the authentication flow. This enrichment enhances the security token with supplementary information that can be useful for authorization decisions and user context. Common examples include:

  • Customer ID from your CRM system
  • User ID from an external profile store
  • Authorization context from your application
  • Role information from Fine-Grained Authorization systems like OpenFGA

The enrichment happens through a REST API call with a fixed contract defined by the Entra ID team. At the end of this post, you’ll find the complete API contract specification.

Common Business Cases

Picture 1.0

  • Enrich the token with the external system user ID. For the cases when the Identity Provider is the only technical component for the authentication and the user’s data (also user profile) is stored in the external system (like CRM, ERP, etc.) applications require to use of the external system user ID to identify the user.
  • Enrich the token with the user role from the Fine Gained Authorization system. Per each authentication request the token will be enriched with the user role from the FGA system. This will allow us to use the role in the application authorization logic.

How it works

Picture 1.1 Like on the picture 1.1, the token enrichment process is triggered during the token issuance process. The Entra External ID platform sends a request(with predefined contract) to created by us API Service (endpoint). The endpoint processes the request and returns (also predefined by Entra External ID Team contract) the additional claims to be added to the token. The token is then issued with the selected by application configuration claims.

Contract

Token Enrichment data contract (C#) known as Token issuance start event listener

Request

Code 1.2 C# contracts

   public class OnTokenIssuanceStartRequest
    {
        [JsonPropertyName("@odata.type")]
        public string odatatype { get; set; }
        public string tenantId { get; set; }
        [JsonPropertyName("authenticationEventListenerId")]
        public string authenticationEventListenerId { get; set; }
        [JsonPropertyName("customAuthenticationExtensionId")]
        public string customAuthenticationExtensionId { get; set; }
        [JsonPropertyName("authenticationContext")]
        public AuthenticationContext authenticationContext { get; set; }
    }
   public class AuthenticationContextRequest
    {
        [JsonPropertyName("correlationId")]
        public string CorrelationId { get; set; }

        [JsonPropertyName("client")]
        public AuthenticationContextClientRequest Client { get; set; }

        [JsonPropertyName("protocol")]
        public string Protocol { get; set; }

        [JsonPropertyName("clientServicePrincipal")]
        public AuthenticationContextServicePrincipalRequest ClientServicePrincipal { get; set; }

        [JsonPropertyName("resourceServicePrincipal")]
        public AuthenticationContextServicePrincipalRequest ResourceServicePrincipal { get; set; }

        [JsonPropertyName("user")]
        public AuthenticationContextUserRequest? User { get; set; }
    }

    public class AuthenticationContextClientRequest
    {
        [JsonPropertyName("ip")]
        public string Ip { get; set; }

        [JsonPropertyName("locale")]
        public string Locale { get; set; }

        [JsonPropertyName("market")]
        public string Market { get; set; }
    }

    public class AuthenticationContextServicePrincipalRequest
    {
        [JsonPropertyName("id")]
        public string Id { get; set; }

        [JsonPropertyName("appId")]
        public string AppId { get; set; }

        [JsonPropertyName("appDisplayName")]
        public string AppDisplayName { get; set; }

        [JsonPropertyName("displayName")]
        public string DisplayName { get; set; }
    }

    public class AuthenticationContextUserRequest
    {
        [JsonPropertyName("displayName")]
        public string? DisplayName { get; set; }

        [JsonPropertyName("id")]
        public string? ObjectId { get; set; }
        
        [JsonPropertyName("userPrincipalName")]
        public string? UserPrincipalName { get; set; }
        
        [JsonPropertyName("userType")]
        public string? UserType { get; set; }

        [JsonPropertyName("mail")]
        public string? Mail { get; set; }
    }
JSON example

The contract details (based on the C# contract) will be accessible in our API endpoint.

Code 1.3 JSON example

{
   "type": "microsoft.graph.authenticationEvent.tokenIssuanceStart",
   "source": "/tenants/52270bb2-ed91-4b79-9314-1af808682b4f/applications/92180e18-3ffb-4597-a7a4-18e97805da8f",
   "data": {
       "@odata.type": "microsoft.graph.onTokenIssuanceStartCalloutData",
       "tenantId": "52270bb2-ed91-4b79-9314-1af808682b4f",
       "authenticationEventListenerId": "870b22e5-c4f9-496e-b786-f32f322c52ab",
       "customAuthenticationExtensionId": "b35f4fbd-13c0-42e4-a768-369faca5dce1",
       "authenticationContext": {
           "correlationId": "fd8ca218-49af-491e-803c-cf8ffc12f128",
           "client": {
               "ip": "188.11.22.11",
               "locale": "en-gb",
               "market": "en-gb"
           },
           "protocol": "OAUTH2.0",
           "clientServicePrincipal": {
               "id": "56a95c8e-f4a7-44ce-9659-038881130780",
               "appId": "92180e18-3ffb-4597-a7a4-18e97805da8f",
               "appDisplayName": "TF.Demo.E2E.Web",
               "displayName": "TF.Demo.E2E.Web"
           },
           "resourceServicePrincipal": {
               "id": "56a95c8e-f4a7-44ce-9659-038881130780",
               "appId": "92180e18-3ffb-4597-a7a4-18e97805da8f",
               "appDisplayName": "TF.Demo.E2E.Web",
               "displayName": "TF.Demo.E2E.Web"
           },
           "user": {
               "createdDateTime": "2024-02-08T12:25:25Z",
               "displayName": "Mateusz Jendza",
               "givenName": "",
               "id": "79d82d2e-1664-4c1f-b11f-d7025e08ce78",
               "mail": "[email protected]",
               "surname": "19104558",
               "userPrincipalName": "79d82d2e-1664-4c1f-b11f-d7025e08ce78@myciamtenant.onmicrosoft.com",
               "userType": "Member"
           }
       }
   }
}

Response Contract

   public class TokenIssuanceStartResponse
   {
       [JsonPropertyName("data")]
       public TokenIssuanceStartResponseData data { get; set; }
       public TokenIssuanceStartResponse()
       {
           data = new TokenIssuanceStartResponse_Data();
           data.odatatype = "microsoft.graph.onTokenIssuanceStartResponseData";

           this.data.actions = new List<TokenIssuanceStartResponseAction>();
           this.data.actions.Add(new TokenIssuanceStartResponseAction());
       }
   }

   public class TokenIssuanceStartResponseData
   {
       [JsonPropertyName("@odata.type")]
       public string odatatype { get; set; }
       public List<TokenIssuanceStartResponseAction> actions { get; set; }
   }

   public class TokenIssuanceStartResponseAction
   {
       [JsonPropertyName("@odata.type")]
       public string odatatype { get; set; }

       [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
       public TokenIssuanceStartResponseClaims claims { get; set; }

       public TokenIssuanceStartResponseAction()
       {
           odatatype = "microsoft.graph.tokenIssuanceStart.provideClaimsForToken";
           claims = new TokenIssuanceStartResponse_Claims();
       }
   }

   public class TokenIssuanceStartResponseClaims
   {
       [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
       public string CorrelationId { get; set; }
       
       [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
       public string CrmId { get; set; }
       
       [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
       public string ApiVersion { get; set; }

       public TokenIssuanceStartResponse_Claims()
       {
           CustomRoles = new List<string>();
       }
   }
JSON example

The response contract (based on the C# contract) will returned and accepted by Entra External ID.

{
   "data": {
       "@odata.type": "microsoft.graph.onTokenIssuanceStartResponseData",
       "actions": [
           {
               "@odata.type": "microsoft.graph.tokenIssuanceStart.provideClaimsForToken",
               "claims": {
                   "correlationId": "fd8ca218-49af-491e-803c-cf8ffc12f128",
                   "globalId": "354970525",
                   "apiVersion": "1.0.0.0",
                   "age": "19"
               }
           }
       ]
   }
}

Technical possible scenarios based on the contract

Picture 1.4.1 As is well known, each business case is unique, but we can identify some common scenarios.

Remember Custom implementation will be needed. We need to provide a custom API endpoint that will be called by the Entra External ID platform with a configuration like on the screen:

Picture 1.4.1

Core cases covered by the contract

  • Application ID (by JSON: resourceServicePrincipal.AppId)- This enables us to verify the application context, allowing us to provide tailored data distinct to each application, similar to the built-in roles that are specific to their applications.
  • UserID - Using the UserID, we can assess the user context and deliver personalized data for every user.
  • UserType - We can meet dedicated requirements based on the user type: guest users and federated accounts. In B2B contexts, we can leverage a ‘customer’ or ‘partner’ authorization store to retrieve or extend necessary claims. Do we have only one federation with our workforce Identity Provider or do we want to build a specific logic for federations?

Additional Considerations

  • IP Address of the client (user) - This helps us ascertain the user’s location, imagine that we can provide dedicated data based on the user’s location. Dedicated token for the user’s location (office vs home).
  • Protocol (OIDC, SAML) - Checking the protocol facilitates the provision of relevant data tailored to the specific protocol in use. In many cases, SAML is connected with Enterprise customers, and in others with legacy systems with can be disabled and replaced with modern OIDC; maybe the token enrichment for SAML will return fewer permissions, or in opposite more - all depends only on your requirements.
  • TenantId - In a multi-tenant setup, delivering dedicated data per tenant is crucial; we can effectively maintain one global authorization store while utilizing dedicated tenants based on location.

Technical Insights

Picture 1.5.1

  • Token Enrichment must be established for each App Registration independently, as there is currently no centralized method for enriching tokens across all applications.
  • It’s essential to recognize that incorporating ‘additional’ logic during each authentication should not occur within token enrichment (referencing point #1). Instead, we should log audits and sign-in records to update details like the last sign-in time in the user profile effectively.
  • The configuration process requires a couple of steps in different places; while some elements can be set via Graph API endpoint and automated per tenant, final adjustments must occur for each App Registration. Perfect place for automated E2E tests (with Postman or Playwright) is advisable to ensure tokens are accurately enriched with the anticipated data. Additionally, the ‘Authentication Events’ tab in the Enterprise Application blade (Picture 1.7) is the first place to verify proper token enrichment configuration.
  • The logic is executed during each token issuance, necessitating that the endpoint be adequately prepared for your authentication traffic. Remember that in many cases the context is ‘static’ and can be cached for a specific time. A good option is to use a key-value store like CosmosDB to ‘cache’ the data per user with a defined TTL. We can review how the basic cache can work with Entra External ID Token Enrichment in Picture 1.5.2.

Picture 1.5.2 CosmosDB Cache for Token Enrichment: Picture 1.5.2

Common Issues & Troubleshooting Guide

Picture 1.6

Common Issues

  1. Token Scope Issues

    • Token enrichment only applies to tokens issued for your application scope (App Registration: app_id).
    • Always verify the audience claim in your tokens.
    • Tokens issued for GraphAPI (audience: 00000003-0000-0000-c000-000000000000, https://graph.microsoft.com/) will not be enriched.
  2. Integration Challenges

    • Start with the simplest flow (e.g., Implicit Flow with ID Token).
    • Ensure your endpoint meets the required response time SLA (hard requirement is less than 2000ms).
    • Verify all required claims are present in the response. I prefer to use Playwright for E2E tests to improve the quality of the integration.

Troubleshooting Steps

  1. Check the ‘Activity Details -> Sign-ins’ in the Enterprise Application blade.
  2. Verify endpoint logs for any errors or timeouts.
  3. Test your endpoint independently using the sample request payload.
  4. Be 100% sure that the authentication flow is for your App Registration.

Development Tips

  • Create a sandbox environment for testing (dedicated tenant).
  • Use the provided sample payloads for initial development.
  • Implement proper logging and monitoring from day one (your token enrichment API & ‘Activity Details -> Sign-ins’).

Picture 1.7 Activity Details->Sign-ins: Picture 1.7