Using configurable token lifetimes in Microsoft Entra ID, .NET and Microsoft Graph

Configurable token lifetimes in the Microsoft identity platform went generally available recently, and it is worth walking through how to use this feature from .NET code rather than only from PowerShell or the Azure portal. This article implements the feature using a .NET console application and the Microsoft Graph SDK, covering both an application client credential flow and a delegated user credential flow.

The idea behind the feature is straightforward. You can shorten the lifetime of an access token issued to a specific application, which reduces the window an attacker has if a token gets leaked or replayed. This matters most for application access tokens used in administration flows, for example a background job that calls Graph APIs with elevated permissions to manage Entra ID objects. A shorter lifetime on that token limits the blast radius if the secret or the token itself is ever exposed.

What the demo does

The sample project builds a token lifetime policy definition, checks whether a policy with the same display name already exists, creates or updates it, and then assigns it to a target service principal. The same service class is reused from two entry points in the console app: one that authenticates with a client secret (application permissions) and one that authenticates with an interactive browser sign in (delegated permissions).

A quick honest note here. The original author mentions the code was drafted with Copilot and the Microsoft documentation, and then cleaned up by hand because the generated version had a number of issues. That matches what most of us see day to day with AI generated Graph SDK code: the shape is usually right, but the filter syntax, null handling and request configuration lambdas need a manual pass before the code compiles and behaves correctly. Treat AI output here as a first draft, not a final answer, and this code itself is still a demo rather than something to run unmodified in production.

The token lifetime policy service

The core logic lives in a single service class. It resolves the target service principal by application ID, builds the policy definition as a JSON string, and either patches an existing policy or creates a new one before linking it to the service principal.

using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Graph;
using Microsoft.Graph.Models;
 
namespace EntraIdTokenLifeTimePolicies.Core;
 
public sealed class TokenLifetimePolicyService(GraphServiceClient graphServiceClient,
    IOptions<TokenLifetimePolicyOptions> options, ILogger<TokenLifetimePolicyService> logger) 
{
    private readonly GraphServiceClient _graphServiceClient = graphServiceClient;
    private readonly TokenLifetimePolicyOptions _options = options.Value;
    private readonly ILogger<TokenLifetimePolicyService> _logger = logger;
 
    public async Task ApplyPolicyAsync(CancellationToken cancellationToken = default)
    {
        ValidateOptions();
 
        var servicePrincipal = await FindServicePrincipalAsync(_options.TargetApplicationClientId, cancellationToken);
        if (servicePrincipal?.Id is null)
        {
            throw new InvalidOperationException(
                $"No service principal was found for application client ID '{_options.TargetApplicationClientId}'.");
        }
 
        var policyDefinition = BuildPolicyDefinition(_options.AccessTokenLifetimeMinutes);
        var policy = await UpsertPolicyAsync(policyDefinition, cancellationToken);
 
        if (policy.Id is null)
        {
            throw new InvalidOperationException("The created or updated token lifetime policy does not contain an ID.");
        }
 
        await AssignPolicyToServicePrincipalAsync(servicePrincipal.Id, policy.Id, cancellationToken);
    }
 
    private async Task<ServicePrincipal?> FindServicePrincipalAsync(string appId, CancellationToken cancellationToken)
    {
        var response = await _graphServiceClient.ServicePrincipals.GetAsync(requestConfiguration =>
        {
            requestConfiguration.QueryParameters.Filter = $"appId eq '{EscapeFilterValue(appId)}'";
            requestConfiguration.QueryParameters.Top = 1;
            requestConfiguration.QueryParameters.Select = ["id", "appId", "displayName"];
        }, cancellationToken);
 
        var servicePrincipal = response?.Value?.FirstOrDefault();
        _logger.LogInformation("Resolved target service principal: {DisplayName} ({ServicePrincipalId})", servicePrincipal?.DisplayName, servicePrincipal?.Id);
        return servicePrincipal;
    }
 
    private async Task<TokenLifetimePolicy> UpsertPolicyAsync(string definition, CancellationToken cancellationToken)
    {
        var existingPolicies = await _graphServiceClient.Policies.TokenLifetimePolicies.GetAsync(requestConfiguration =>
        {
            requestConfiguration.QueryParameters.Filter = $"displayName eq '{EscapeFilterValue(_options.PolicyDisplayName)}'";
            requestConfiguration.QueryParameters.Top = 1;
            requestConfiguration.QueryParameters.Select = ["id", "displayName", "definition"];
        }, cancellationToken);
 
        var existingPolicy = existingPolicies?.Value?.FirstOrDefault();
        var updateBody = new TokenLifetimePolicy
        {
            Definition = [definition],
            IsOrganizationDefault = false,
            DisplayName = _options.PolicyDisplayName,
        };
 
        if (existingPolicy?.Id is not null)
        {
            _logger.LogInformation("Updating existing token lifetime policy: {PolicyId}", existingPolicy.Id);
            await _graphServiceClient.Policies.TokenLifetimePolicies[existingPolicy.Id].PatchAsync(updateBody, cancellationToken: cancellationToken);
            existingPolicy.Definition = updateBody.Definition;
            return existingPolicy;
        }
 
        _logger.LogInformation("Creating token lifetime policy: {PolicyDisplayName}", _options.PolicyDisplayName);
        var createdPolicy = await _graphServiceClient.Policies.TokenLifetimePolicies.PostAsync(updateBody, cancellationToken: cancellationToken);
        return createdPolicy ?? throw new InvalidOperationException("Microsoft Graph returned null while creating a token lifetime policy.");
    }
 
    private async Task AssignPolicyToServicePrincipalAsync(string servicePrincipalId, string policyId, CancellationToken cancellationToken)
    {
        var existingAssignments = await _graphServiceClient.ServicePrincipals[servicePrincipalId].TokenLifetimePolicies.GetAsync(
            requestConfiguration =>
            {
                requestConfiguration.QueryParameters.Select = ["id"];
            },
            cancellationToken);
 
        if (existingAssignments?.Value?.Any(policy => string.Equals(policy.Id, policyId, StringComparison.OrdinalIgnoreCase)) == true)
        {
            _logger.LogInformation("Policy {PolicyId} is already assigned to service principal {ServicePrincipalId}.", policyId, servicePrincipalId);
            return;
        }
 
        var reference = new ReferenceCreate
        {
            OdataId = $"{_graphServiceClient.RequestAdapter.BaseUrl}/policies/tokenLifetimePolicies/{policyId}",
        };
 
        _logger.LogInformation("Assigning policy {PolicyId} to service principal {ServicePrincipalId}.", policyId, servicePrincipalId);
        await _graphServiceClient.ServicePrincipals[servicePrincipalId].TokenLifetimePolicies.Ref.PostAsync(reference, cancellationToken: cancellationToken);
    }
 
    private static string BuildPolicyDefinition(int accessTokenLifetimeMinutes)
    {
        var policy = new
        {
            TokenLifetimePolicy = new
            {
                Version = 1,
                AccessTokenLifetime = $"00:{accessTokenLifetimeMinutes}:00",
            },
        };
 
        return JsonSerializer.Serialize(policy);
    }
 
    private void ValidateOptions()
    {
        if (string.IsNullOrWhiteSpace(_options.TargetApplicationClientId))
        {
            throw new InvalidOperationException("TokenLifetimePolicy:TargetApplicationClientId is required.");
        }
 
        if (string.IsNullOrWhiteSpace(_options.PolicyDisplayName))
        {
            throw new InvalidOperationException("TokenLifetimePolicy:PolicyDisplayName is required.");
        }
 
        if (_options.AccessTokenLifetimeMinutes is < 10 or > 1440)
        {
            throw new InvalidOperationException("TokenLifetimePolicy:AccessTokenLifetimeMinutes must be between 10 and 1440.");
        }
    }
 
    private static string EscapeFilterValue(string value) => value.Replace("'", "''", StringComparison.Ordinal);
}

A few things worth noting in this class. The filter queries used to find the service principal and the existing policy both escape single quotes manually before building the OData filter string, which is a small but easy to miss detail when you build Graph filters by string concatenation. The class also validates the configured lifetime against a 10 to 1440 minute range before doing anything else, which matches the bounds Microsoft documents for this policy type, so an invalid value fails fast instead of failing later inside a Graph call.

Applying the policy with application permissions

The application permission flow does not involve a user at all. An Azure App Registration is set up with the required Graph application permissions, and the console app authenticates with a client secret using the client credentials flow. This is fine for a demo, but a client secret sitting in configuration is not something you want in a real environment. A managed identity is the better option when the code runs inside Azure, and a client assertion (certificate based) is the better option for anything running outside Azure. Client credentials with a secret should never be the flow you pick when a user identity is involved.

Application permissions configured on the Azure App Registration for the client credentials flow
Application permissions configured on the Azure App Registration for the client credentials flow

The ClientSecretCredential class from Azure.Identity is used to acquire the application access token, and that credential is handed to the GraphServiceClient constructor along with the default Graph scope.

builder.Services.AddSingleton(sp =>
{
    var authOptions = sp
     .GetRequiredService<IOptions<ApplicationAuthenticationOptions>>().Value;
 
    var credential = new ClientSecretCredential(
        authOptions.TenantId,
        authOptions.ClientId,
        authOptions.ClientSecret);
 
    return new GraphServiceClient(credential,
      ["https://graph.microsoft.com/.default"]);
});

Registering the GraphServiceClient as a singleton keyed off options bound at startup is a common pattern in these console app samples. Once this is wired up, calling the Graph API is just a matter of resolving TokenLifetimePolicyService from the container and calling ApplyPolicyAsync, as shown below.

var authenticationOptions = host.Services
           .GetRequiredService<IOptions<ApplicationAuthenticationOptions>>();
var tokenLifetimePolicyService = host.Services
           .GetRequiredService<TokenLifetimePolicyService>();
 
ApplicationAuthenticationOptions.Validate(authenticationOptions.Value);
 
logger.LogInformation("Starting app-only flow for tenant {TenantId}.", 
       authenticationOptions.Value.TenantId);
 
logger.LogInformation("Required application permissions: {Permissions}", 
      string.Join(", ", 
         authenticationOptions.Value.RequiredApplicationPermissions));
 
await tokenLifetimePolicyService.ApplyPolicyAsync(CancellationToken.None);

This block validates the options object first and logs the tenant and the required permissions before calling the service. Logging the required permissions up front is a small habit worth copying into your own admin tooling, because when a Graph call fails with a 403, you want the log line right above it telling you exactly what permission the code expected to have.

Testing the application access token

Here is the detail that trips people up the first time they use this feature: the token lifetime policy applies to tokens issued for the target App Registration, not to the Graph API token used to manage the policy itself. So to actually see the shortened lifetime in effect, you request a token for the target application’s own API surface, not for Graph.

static async Task TestApplicationTokenPolicy(IHost host, ILogger logger)
{
    // Test token
    var authOptions = host.Services.GetRequiredService<IOptions<ApplicationAuthenticationOptions>>().Value;
    var credential = new ClientSecretCredential(authOptions.TenantId, authOptions.ClientId, authOptions.ClientSecret);
 
    // Request token for the API (Policy only applies to App registrion, not graph)
    var context = new TokenRequestContext(["api://1ff3f063-8b62-43d7-b323-956291bec8e5/.default"]);
    var response = await credential.GetTokenAsync(context);
 
    logger.LogInformation("Token acquired UTC: {ExpiresIn}, {Token}", response.ExpiresOn, response.Token);
}

Running this logs the token expiry timestamp, which is the fastest way to confirm the policy actually took effect. If you request a token for Graph itself instead of the target API, the expiry will still show the Microsoft default lifetime and it will look like the policy did nothing, when in reality you just tested against the wrong resource.

Applying the policy with delegated permissions

Delegated access tokens should be your default choice whenever a real user is present, and this sample shows the same policy service invoked through an OpenID Connect flow instead of client credentials. Only delegated permissions are requested here, and the user goes through an interactive sign in rather than the app authenticating on its own.

Delegated permissions configured on the Azure App Registration for the interactive browser flow
Delegated permissions configured on the Azure App Registration for the interactive browser flow

The console app uses InteractiveBrowserCredential from Azure.Identity, which pops open a browser window for the user to sign in and consent. This is a public client, meaning it does not use a client secret at all.

builder.Services.AddSingleton(sp =>
{
    var authOptions = sp.GetRequiredService<IOptions<DelegatedAuthenticationOptions>>().Value;
 
    var credentialOptions = new InteractiveBrowserCredentialOptions
    {
        ClientId = authOptions.ClientId,
        TenantId = authOptions.TenantId,
        RedirectUri = new Uri("http://localhost"), 
    };
 
    var credential = new InteractiveBrowserCredential(credentialOptions);
    return new GraphServiceClient(credential, authOptions.RequiredDelegatedScopes);
});

Note the redirect URI is a plain localhost address, which is standard for native and console app OAuth flows and needs to be registered exactly as a mobile and desktop application platform on the App Registration, not as a web platform redirect URI. Getting that registration wrong is one of the more common setup mistakes with InteractiveBrowserCredential and it usually surfaces as a cryptic redirect_uri_mismatch error at sign in time.

The service call itself looks almost identical to the application permission version, which is the benefit of centralizing the policy logic in one class regardless of how the token was acquired.

var tokenLifetimePolicyService = host.Services.GetRequiredService<TokenLifetimePolicyService>();
var authenticationOptions = host.Services.GetRequiredService<IOptions<DelegatedAuthenticationOptions>>();
 
DelegatedAuthenticationOptions.Validate(authenticationOptions.Value);
 
logger.LogInformation("Starting delegated flow for tenant {TenantId}.", authenticationOptions.Value.TenantId);
logger.LogInformation("Delegated scopes requested: {Scopes}", string.Join(", ", authenticationOptions.Value.RequiredDelegatedScopes));
await tokenLifetimePolicyService.ApplyPolicyAsync(CancellationToken.None);

Testing the delegated access token

Testing follows the same pattern as the application flow. An App Registration exposes a custom scope, access_as_user in this sample, and the code requests a token for that scope after the interactive sign in completes.

static async Task TestDelegatedTokenPolicy(IHost host, ILogger logger)
{
    // Test token
    var authOptions = host.Services
           .GetRequiredService<IOptions<DelegatedAuthenticationOptions>>().Value;
 
    var credentialOptions = new InteractiveBrowserCredentialOptions
    {
        ClientId = authOptions.ClientId,
        TenantId = authOptions.TenantId,
        RedirectUri = new Uri("http://localhost"),
    };
    var credential = new InteractiveBrowserCredential(credentialOptions);
 
    // Request token for the API (Policy only applies to App registrion, not graph)
    var context = new TokenRequestContext(
            ["api://9949e3d8-ffb2-4e86-908a-fd92b6140972/access_as_user"]);
 
    var response = await credential.GetTokenAsync(context);
 
    logger.LogInformation("Token acquired UTC: {ExpiresIn}, {Token}",
                response.ExpiresOn, response.Token);
}

As with the application flow, the expiry timestamp on the response is what confirms the policy applied. Because this is an interactive credential, expect a browser popup on every run unless you add token caching, which the sample does not include and which you would want to add before using this pattern in any tool people run repeatedly through the day.

Production considerations

A few trade offs are worth calling out before you lift this pattern into a real project. Client secrets, used here for the application flow, are the weakest option available for client credentials. Prefer a managed identity when the workload runs in Azure, or a certificate backed client assertion when it does not, and treat a client secret as a stopgap for local testing only.

Token lifetime policies are also being retired in favour of Conditional Access authentication session management for some scenarios, so before you invest heavily in this API for a new project, check the current Microsoft Entra documentation for whether token lifetime policies remain the recommended mechanism for your specific use case or whether Conditional Access session controls now cover it. Microsoft has moved capability between these two systems before, and the configurable token lifetime feature going GA does not mean it is the newest or the only tool for this job.

Finally, remember that shortening a token lifetime does not replace proper permission scoping. A short lived token with excessive Graph permissions is still a bigger risk than a longer lived token with tightly scoped permissions. Use configurable lifetimes as a defense in depth measure on top of least privilege access, not as a substitute for it.

Wrapping up

This was a genuinely quick feature to wire up once the right Graph permissions and the correct token acquisition target were sorted out. The Microsoft documentation demonstrates the PowerShell version, but converting that to .NET with Microsoft Graph SDK calls is a fairly mechanical exercise once you know the two gotchas covered above: escaping OData filter values yourself, and remembering that the policy shows up on the target application’s own tokens, not on Graph API tokens.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading