Using Managed Identity Tokens with Azure SQL Connection Pools

This guide is part of Azure SQL Connection Management. A managed identity removes the connection string password: the application asks Entra ID for an access token and presents it when opening a connection. SqlClient supports this in two ways, and the difference matters for pooling. Setting Authentication=Active Directory Default in the connection string lets the driver acquire and cache tokens itself; setting SqlConnection.AccessToken puts the application in control. Both work — until a token is embedded somewhere that becomes part of the pool key, or the application caches a token past its lifetime:

Microsoft.Data.SqlClient.SqlException (0x80131904): Login failed for user ''.
  Token is expired.  Error Number:18456, State:1, Class:14

System.InvalidOperationException: Timeout expired. The timeout period elapsed prior to obtaining
a connection from the pool.

The first is an expired token; the second is often what happens after one — the pool retries, every attempt fails, and callers queue. The objective is a configuration where tokens are fresh, the pool key is constant, and an expiring token never affects an established connection.

Rapid incident diagnosis

Check how authentication is configured. The two mechanisms need different fixes:

grep -rn "Authentication=Active Directory\|AccessToken" --include=*.cs --include=*.json src/ | head

Count the pools. If a token or anything else varies in the connection string, SqlClient creates a pool per variant:

dotnet-counters monitor --process-id $(pgrep -f Orders.Api) \
  --counters Microsoft.Data.SqlClient.EventSource[number-of-pools,number-of-active-connections,active-hard-connections]

number-of-pools above one per database and identity means fragmentation — the failure mode described in Diagnosing SqlClient Connection Pool Fragmentation.

Look at the timing of login failures. Access tokens for Azure SQL are valid for roughly an hour with some variability. Failures clustered at hourly intervals after startup indicate a token cached by the application past its expiry; failures immediately at startup indicate a missing role assignment or a wrong audience.

Verify the database principal exists. The identity must be a contained user in the database:

SELECT name, type_desc, authentication_type_desc
FROM sys.database_principals
WHERE name = 'orders-api';    -- the managed identity's display name
-- create it, from a connection authenticated as an Entra admin
CREATE USER [orders-api] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [orders-api];
ALTER ROLE db_datawriter ADD MEMBER [orders-api];

Check the token’s audience. Azure SQL expects https://database.windows.net/.default; a token minted for another scope fails with the same login error.

Token lifetime against connection lifetime A token is valid for about an hour and authenticates connections opened during that window. Connections established with it remain valid afterwards, so only new connections need a refreshed token. One hour of pool activity token valid: new connections authenticate token expired: refresh needed for new connections a connection opened at the start stays valid through the whole window the failure mode is a new connection with a stale token, not an old connection with an expired one
The token authenticates a connection when it is opened. Established connections keep working after it expires; only new connections need a fresh one.

Mathematical sizing or parameter formula

Token acquisition rate follows connection creation:

tokens_needed_per_hour ≈ connections_created_per_hour ÷ tokens_cached

With a shared cached token, one acquisition per refresh interval per process is enough regardless of connection rate. Without caching — a token acquired per connection — the rate follows connection churn, and each acquisition is a network call to the identity endpoint unless the SDK’s cache serves it.

Refresh margin. Refresh before expiry, not at it:

refresh_at = expiry − max(5 min, connect_timeout + retry_window)

A five-minute margin covers a slow connect and a retry without ever presenting a token that expires mid-handshake.

Pool sizing is unchanged by the authentication method, but the failure mode during a token problem is a burst of connection attempts:

failed_attempts = pool_max × replicas × retries_per_attempt

Bounding retries keeps a token misconfiguration from hammering both Entra ID and the database.

Identity endpoint throttling. The instance metadata or container identity endpoint serves tokens locally and caches them; the Azure SDK’s DefaultAzureCredential caches in-process as well. Acquiring per connection without any cache can hit endpoint limits during a reconnect burst:

burst_acquisitions = replicas × pool_max        (after a failover or deploy)

Twelve replicas with a pool of 20 is 240 acquisitions within a second or two — comfortably served from cache, and potentially throttled without one.

Token acquisitions during a reconnect burst With a cached token, a burst of 240 new connections needs one token acquisition per process. Without caching, it needs one per connection. Token acquisitions when 240 connections are recreated cached per process 12 per connection 240 the SDK caches in-process, so the second case usually only appears with custom code that calls the token endpoint directly on every connect
One cached token per process serves every connection it opens. Acquiring per connection turns a reconnect burst into a burst of identity requests as well.

Exact remediation & configuration

Simplest correct option: let the driver handle it. With Authentication=Active Directory Default, SqlClient acquires and caches tokens through the Azure Identity library, and the connection string contains no secret and never changes:

{
  "ConnectionStrings": {
    "Orders": "Server=tcp:orders-sql.database.windows.net,1433;Database=orders;Authentication=Active Directory Default;Encrypt=True;TrustServerCertificate=False;Connect Timeout=15;Min Pool Size=0;Max Pool Size=40;ConnectRetryCount=3;ConnectRetryInterval=10"
  }
}
builder.Services.AddDbContextPool<OrdersContext>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("Orders"),
        sql => sql.EnableRetryOnFailure(6, TimeSpan.FromSeconds(15), new[] { 40613, 40197, 49918 })));

Where the application must control the token — a user-assigned identity chosen at runtime, or a federated credential — set AccessToken on the connection and keep the connection string constant:

public sealed class TokenSqlConnectionFactory
{
    private static readonly string[] Scopes = { "https://database.windows.net/.default" };
    private readonly TokenCredential _credential = new DefaultAzureCredential();
    private readonly string _connectionString;
    private AccessToken _cached;
    private readonly SemaphoreSlim _lock = new(1, 1);

    public TokenSqlConnectionFactory(string connectionString) => _connectionString = connectionString;

    private async Task<string> GetTokenAsync(CancellationToken ct)
    {
        if (_cached.ExpiresOn > DateTimeOffset.UtcNow.AddMinutes(5)) return _cached.Token;
        await _lock.WaitAsync(ct);
        try
        {
            if (_cached.ExpiresOn <= DateTimeOffset.UtcNow.AddMinutes(5))
                _cached = await _credential.GetTokenAsync(new TokenRequestContext(Scopes), ct);
            return _cached.Token;
        }
        finally { _lock.Release(); }
    }

    public async Task<SqlConnection> OpenAsync(CancellationToken ct)
    {
        var conn = new SqlConnection(_connectionString)     // constant: one pool
        {
            AccessToken = await GetTokenAsync(ct),          // fresh, not part of the pool key
        };
        await conn.OpenAsync(ct);
        return conn;
    }
}

The single SemaphoreSlim prevents a thundering herd of token acquisitions when many threads notice the expiry at once.

With Entity Framework Core and a custom token, supply the connection rather than the string:

builder.Services.AddDbContext<OrdersContext>((sp, o) =>
{
    var factory = sp.GetRequiredService<TokenSqlConnectionFactory>();
    var conn = factory.OpenAsync(CancellationToken.None).GetAwaiter().GetResult();
    o.UseSqlServer(conn);
});

For pooled contexts, prefer the driver-managed option, because AddDbContextPool expects a stable configuration.

Grant the identity in the database, once, from an Entra-authenticated session:

CREATE USER [orders-api] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [orders-api];
ALTER ROLE db_datawriter ADD MEMBER [orders-api];
GRANT EXECUTE ON SCHEMA::dbo TO [orders-api];

Keep retries bounded and typed. Login failures from an expired token are transient in the sense that a refresh fixes them, but retrying without refreshing is pointless:

catch (SqlException ex) when (ex.Number == 18456)
{
    _cached = default;        // force a refresh on the next attempt
    throw;
}

Do not put the token in the connection string. Password=<token> fragments the pool on every refresh and leaks a credential into anything that logs the string.

Operating identity-based authentication

Watch for hourly error clusters. The clearest signal of a token-handling bug is a small burst of login failures at roughly hourly intervals. An alert on the error number is precise:

- alert: AzureSqlTokenLoginFailures
  expr: increase(app_db_errors_total{number="18456"}[15m]) > 0
  labels: { severity: warning }
  annotations:
    summary: "{{ $labels.service }}: Azure SQL login failures — check token refresh"

Keep the pool count at one. Fragmentation from tokens is silent until the connection count matters; the counter makes it visible:

count by (service) (sqlclient_number_of_pools) > 1

Rehearse an identity change. Rotating to a different managed identity, or adding a user-assigned identity, changes the principal the database must know. Rehearse it in staging: create the new principal, grant roles, switch the configuration, and confirm no window of login failures.

Mind the local development path. DefaultAzureCredential falls back to a developer’s own identity locally, which usually lacks the database roles. Granting a developer group the same database roles, or using a separate local database, avoids the confusing situation where the same code works in Azure and fails on a laptop.

Understand what the token does not do. It authenticates; it does not authorise beyond the database roles granted to the principal, and it does not affect pooling behaviour, throttling limits or session state. Connection limits are still the tier’s, as described in Handling Azure SQL Connection Throttling.

Aspect Driver-managed (Authentication=) Application-managed (AccessToken)
Token caching handled by the driver and SDK your responsibility
Pool key constant constant, if the string is unchanged
Choosing a specific identity via connection string properties full control
Works with AddDbContextPool yes awkward
Failure mode SDK exceptions at open your code’s, at open
Recommended for most services services needing custom credential logic
Choosing between driver-managed and application-managed tokens Driver-managed authentication suits most services, while application-managed tokens are for runtime credential selection, at the cost of owning caching and refresh. Driver-managed Authentication=Active Directory Default SDK caches and refreshes Application-managed AccessToken per connection you cache and refresh Same result, different ownership both keep the connection string constant, which is what protects the pool
The driver-managed path is the default choice. Application-managed tokens exist for cases where the credential must be selected at runtime.

Validation & verification

One pool, one identity. With traffic running, number-of-pools must be 1 per database, and the database must show a single principal:

SELECT login_name, COUNT(*) FROM sys.dm_exec_sessions WHERE is_user_process = 1 GROUP BY login_name;

Token refresh is invisible. Run the service for two hours under steady load; there must be no login failures and no latency spike at the hourly boundary.

Established connections survive expiry. Open a connection, wait past the token’s expiry without traffic, then use it; it must still work — which confirms that only new connections need a token.

Failures are attributable. Remove the database role in staging and confirm the resulting error names the principal, so the next occurrence is diagnosed in seconds rather than mistaken for a network problem.

Local development works. A developer with the right group membership should be able to run the service against a staging database using their own identity, with the same code path.

Frequently Asked Questions

Does Min Pool Size interact badly with tokens?
A non-zero minimum keeps connections open, which is fine: those connections were authenticated when they were created. It only means more connections to recreate — and more token uses — after a restart or failover.
Is Authentication=Active Directory Managed Identity different from Default?
Default tries a chain of credential sources, which is convenient across environments; the explicit managed identity option skips the chain and fails fast if the identity is not available, which is better in production where the source should be unambiguous.
Can two identities share one pool?
No. Different identities are different principals and, in practice, different connection configurations — so different pools. If a service genuinely needs two identities, budget for two pools.
What about Azure SQL Managed Instance or SQL Server on a VM?
Managed Instance supports the same Entra authentication. SQL Server on a virtual machine can use Entra authentication in recent versions, but many deployments still use SQL authentication, where the credential is a secret to be rotated rather than a token.