Building a Custom Claims Provider in Entra ID: A Deep Dive

Microsoft Entra ID, formerly known as Azure Active Directory, is Microsoft’s cloud-based identity and access management (IAM) service. It enables organizations to manage user identities and control access to applications, devices, and data. 

Entra ID generates tokens that are used for authentication and authorization for enterprise applications. Claims are a set of key value pairs in tokens that applications can use to make decisions about authorization.

Partner with Microsoft experts you can trust

If it’s time to take that first step toward leveling up your organization’s security, get in touch with Ravenswood to start the conversation. 

A custom claims provider allows an identity developer to inject additional claims into tokens issued by Entra ID using custom code. You can use a custom claims provider in a few situations, including: 

1. Integration with static data that is not stored in Entra ID

You may need to integrate Entra ID with data that cannot be saved and stored in Entra ID. A custom claims provider can be used to translate between Entra ID and these systems. For example, a custom claims provider could fetch an additional attribute like careerLevel from an HR database after a user has signed in to Entra ID, providing richer identity information to an application.

2. Attribute Enrichment from External APIs

Organizations might require attributes from third-party services that are not part of Entra. A custom claims provider can query external APIs to gather additional dynamic data during authentication, such as checking a user’s risk profile from a threat intelligence service or validating membership in a third-party loyalty program.

3. Complex Business Logic for Claims Transformation

A company may need to evaluate user roles based on a combination of data points from multiple systems, apply custom mappings, or perform multi-step transformations where the out-of-the-box claims transformation feature will not suffice.

Building a Claims Provider

The Microsoft article “Configure a custom claim provider for a token issuance event“ describes how to build and configure a custom claims provider. Using this as a foundation, I wanted to gain a deeper understanding of how claims providers work. 

One of the prerequisites in the article is a REST API that will return custom claims. Microsoft’s guide on building that API provides an example Azure Function that deserializes incoming JSON requests and returns additional claims. 

Here is a snippet of the code’s core functionality:

// Read the HTTP request body
string requestBody = await new StreamReader(req.Body).ReadToEndAsync();

// Deserialize the request
dynamic data = JsonConvert.DeserializeObject(requestBody);

// Retrieve attributes from the request
string correlationId = data?.data.authenticationContext.correlationId;

// Generate and return claims
ResponseContent r = new ResponseContent();
r.data.actions[0].claims.CorrelationId = correlationId;
r.data.actions[0].claims.ApiVersion = "1.0.0";
r.data.actions[0].claims.DateOfBirth = "01/01/2000";
r.data.actions[0].claims.CustomRoles.Add("Writer");
r.data.actions[0].claims.CustomRoles.Add("Editor");
return new OkObjectResult(r);

This code:

  • Processes the request (reads the incoming JSON payload)
  • Extracts data (deserializes the payload to access details like the correlation ID)
  • Generates claims (constructs a response with hard-coded claims like a date of birth and user roles)

Rather than simply copying and pasting the code, I wanted to know how and why the code works and figure out how to extend it. 

This example code helps us understand exactly what data is being sent to and from Entra ID. For a production claims provider, consider using the NuGet package described in the Microsoft article “Integrate data from external sources into Microsoft Entra tokens using the Authentication Events library.”

To understand what this code was doing, first I needed to figure out what Entra ID was sending the API and then understand what it was sending back. According to the Custom claims provider reference , the JSON that is sent to the API from Entra ID looks like this:

POST https://your-api.com/endpoint

{
    "type": "microsoft.graph.authenticationEvent.tokenIssuanceStart",
    "source": "/tenants/<Your tenant GUID>/applications/<Your Test Application App Id>",
    "data": {
        "@odata.type": "microsoft.graph.onTokenIssuanceStartCalloutData",
        "tenantId": "<Your tenant GUID>",
        "authenticationEventListenerId": "<GUID>",
        "customAuthenticationExtensionId": "<Your custom extension ID>",
        "authenticationContext": {
            "correlationId": "<GUID>",
            "client": {
                "ip": "30.51.176.110",
                "locale": "en-us",
                "market": "en-us"
            },
            "protocol": "OAUTH2.0",
            "clientServicePrincipal": {
                "id": "<Your Application’s servicePrincipal objectId>",
                "appId": "<Your Application App Id>",
                "appDisplayName": "My Test application",
                "displayName": "My Test application"
            },
            "resourceServicePrincipal": {
                "id": "<Your Test Applications servicePrincipal objectId>",
                "appId": "<Your Application App Id>",
                "appDisplayName": "My Test application",
                "displayName": "My Test application"
            },
            "user": {
                "companyName": "Casey Jensen",
                "createdDateTime": "2016-03-01T15:23:40Z",
                "displayName": "Casey Jensen",
                "givenName": "Casey",
                "id": "90847c2a-e29d-4d2f-9f54-c5b4d3f26471", // Client ID representing the Microsoft Entra authentication events service
                "mail": "casey@contoso.com",
                "onPremisesSamAccountName": "caseyjensen",
                "onPremisesSecurityIdentifier": "<Enter Security Identifier>",
                "onPremisesUserPrincipalName": "Casey Jensen",
                "preferredLanguage": "en-us",
                "surname": "Jensen",
                "userPrincipalName": "casey@contoso.com",
                "userType": "Member"
            }
        }
    }
}

I also added a logging statement to echo out the de-serialized request. The example code pulls out the correlation ID, but there is so much more that the developer has access to. Looking at the JSON that is sent to the API, we have access to all kinds of data, including the user’s UPN and Display Name. Microsoft’s sample code simply sends back a few hard-coded claims for custom roles and a date of birth. However, knowing what the userPrincipalName is could be very useful in determining which claims to send back to Entra ID. 

This line of code deserializes the JSON submitted to the API from Entra ID into a dynamic object named data.

dynamic data = JsonConvert.DeserializeObject(requestBody);

Using the dynamic data variable, the UPN can be accessed, as shown below.

string upn = data?.data.authenticationContext.user.userPrincipalName;

This information can be used to determine which roles to add to the claims. For my proof of concept, I used a simple if statement, but the API could pass that UPN to a database query or another API to determine which type of claim to add.

if(upn.ToLowerInvariant().Contains("andy")) 
 {
     r.data.actions[0].claims.CustomRoles.Add("Administrator");
 }
 else 
 {
     r.data.actions[0].claims.CustomRoles.Add("StandardUser");
 }

Now that the claims are known, the API needs to send a response back to Entra ID with those claims. This is where the helper classes shown below come into play.

public class ResponseContent{
    [JsonProperty("data")]
    public Data data { get; set; }
    public ResponseContent()
    {
        data = new Data();
    }
}
public class Data{
    [JsonProperty("@odata.type")]
    public string odatatype { get; set; }
    public List<Action> actions { get; set; }
    public Data()
    {
        odatatype = "microsoft.graph.onTokenIssuanceStartResponseData";
        actions = new List<Action>();
        actions.Add(new Action());
    }
}
public class Action{
    [JsonProperty("@odata.type")]
    public string odatatype { get; set; }
    public Claims claims { get; set; }
    public Action()
    {
        odatatype = "microsoft.graph.tokenIssuanceStart.provideClaimsForToken";
        claims = new Claims();
    }
}
public class Claims{
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
    public string CorrelationId { get; set; }
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
    public string DateOfBirth { get; set; }
    public string ApiVersion { get; set; }
    public List<string> CustomRoles { get; set; }
    public Claims()
    {
        CustomRoles = new List<string>();
    }
}

The four classes, ResponseContent, Data, Action, and Claims, are used to generate the response to send back to Entra ID. Reading the custom claims provider reference, we discover that Entra ID is expecting a JSON object that looks like the following:

{
    "data": {
        "@odata.type": "microsoft.graph.onTokenIssuanceStartResponseData",
        "actions": [
            {
                "@odata.type": "microsoft.graph.tokenIssuanceStart.provideClaimsForToken",
                "claims": {
                    "DateOfBirth": "01/01/2000",
                    "CustomRoles": [
                        "Writer",
                        "Editor"
                    ]
                }
            }
        ]
    }
}

Looking at that JSON, the classes start to make a bit more sense. The ResponseContent holds the data object. The data class contains Actions, and Actions contain Claims. The ResponseContent is returned to Entra ID. 

There are also some odata.type properties specified in the classes that should be noted. These are required so that when the objects are de-serialized into JSON, the proper odata.type is applied.

Putting it all Together

With the understanding of what Entra ID is sending to the API and what the API needs to send back, it is now possible to build an Azure Function to send the custom claims needed for authorization in an application.

Below is my proof-of-concept Azure Function.

#r "Newtonsoft.Json"
using System.Net;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json;

public static async Task<IActionResult> Run(HttpRequest req, ILogger log)
{
    log.LogInformation("C# HTTP trigger function processed a request.");
    string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
    dynamic data = JsonConvert.DeserializeObject(requestBody);
    log.LogInformation($"Request Body: {requestBody}");

    // Read the correlation ID from the Microsoft Entra request    
    string correlationId = data?.data.authenticationContext.correlationId;
    string userDisplayName = data?.data.authenticationContext.user.displayName;
    string upn = data?.data.authenticationContext.user.userPrincipalName;
    log.LogInformation($"UPN  {upn}");

    // Claims to return to Microsoft Entra
    ResponseContent r = new ResponseContent();
    r.data.actions[0].claims.CorrelationId = correlationId;
    r.data.actions[0].claims.ApiVersion = "1.0.0";
    r.data.actions[0].claims.DateOfBirth = "01/01/2000";

    if(upn.ToLowerInvariant().Contains("andy")) 
         r.data.actions[0].claims.CustomRoles.Add("Admin");
    else 
         r.data.actions[0].claims.CustomRoles.Add("User");

    log.LogInformation($"Response data is {JsonConvert.SerializeObject(r)}");
    return new OkObjectResult(r);
}
public class ResponseContent{
    [JsonProperty("data")]
    public Data data { get; set; }
    public ResponseContent()
    {
        data = new Data();
    }
}
public class Data{
    [JsonProperty("@odata.type")]
    public string odatatype { get; set; }
    public List<Action> actions { get; set; }
    public Data()
    {
        odatatype = "microsoft.graph.onTokenIssuanceStartResponseData";
        actions = new List<Action>();
        actions.Add(new Action());
    }
}
public class Action{
    [JsonProperty("@odata.type")]
    public string odatatype { get; set; }
    public Claims claims { get; set; }
    public Action()
    {
        odatatype = "microsoft.graph.tokenIssuanceStart.provideClaimsForToken";
        claims = new Claims();
    }
}
public class Claims{
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
    public string CorrelationId { get; set; }
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
    public string DateOfBirth { get; set; }
    public string ApiVersion { get; set; }
    public List<string> CustomRoles { get; set; }
    public Claims()
    {
        CustomRoles = new List<string>();
    }

Running the application sends the output to https://jwt.ms, and the custom claims show up in the JSON Web Token (JWT) when I log in with my account. 

Similarly, here is a token for a user that does not have the string “andy” in the UPN.

Conclusion

The Microsoft article “Configure a custom claim provider for a token issuance event” does a good job of showing how to add a custom claims provider to Entra ID. There is no reason to rehash the entire article here. However, there are a few things to remember when considering a custom claims provider.

  1. Azure Functions are not required for custom claims providers. All that is needed is an HTTP endpoint that accepts and returns the JSON noted above. Using a simple Azure Function that returned JSON helps us understand what is being sent back and forth between Entra ID and the API, and it is a great place to start exploring. However, if you plan to build a production API, you should consider using the Authentication Events library NuGet package.

  2. A custom claims provider adds latency to every single authentication for applications using it in Entra ID. This means the Azure Function or web server that is hosting the API should be able to scale and handle errors accordingly.

  3. This API is injecting claims into a token. It is used for authentication and authorization in an organization. The security of this API should be the top priority when building and managing it. It should be managed with the same security and processes as any other component of your IAM infrastructure. 

  4. It helps to add some logging into the Azure Function during development to see exactly what is being sent to and from the API. I have logging statements in my Azure Function to view the incoming request and the outgoing response. This is particularly helpful to see which attribute values are coming from Entra ID.

Custom claims providers may be a niche component of Entra ID, but they can be extremely useful for integrating with external IAM systems. The experts at Ravenswood Technology Group specialize in helping organizations design, build, and secure Entra ID. Get in touch with Ravenswood Technology Group today to learn how we can help.

Partner with Microsoft experts you can trust

If it’s time to take that first step toward leveling up your organization’s security, get in touch with Ravenswood to start the conversation. 

[RELEVANT BLOG CONTENT]