Service-to-Service Authentication

Authenticate one service to another with client credentials or managed identities, decide how user context travels, and know where mTLS fits.

SyntaxPOST /connect/token grant_type=client_credentials&client_id=orders-api&client_secret=...&scope=inventory.reserve services.AddHttpClient("inventory").AddHttpMessageHandler<BearerTokenHandler>();

When the orders API calls the inventory API there is often no user at all (a background job reserving stock), or there is a user but the question is whether inventory should trust orders' claim about them. Network location is not authentication: "only reachable inside the cluster" holds until one compromised pod can call everything.

Client credentials

The OAuth 2.0 client credentials grant: the calling service POSTs grant_type=client_credentials, its client id and a credential, and a scope for the target API to the provider's token endpoint, and gets a short-lived access token whose subject is the service. The receiving API validates it with ordinary JWT bearer settings and authorizes on scopes or app roles, not user claims. Prefer a signed client assertion (private_key_jwt) or a certificate over a shared client secret when the provider supports it.

The token must be cached until shortly before expires_in. Fetching one per outgoing request adds a round trip to every call and gets the client throttled by the provider under load. The course uses one design for this, built step by step in the Delegating Handlers lesson: a singleton TokenProvider that caches the token behind a SemaphoreSlim and fetches it through its own identity named client, and a transient BearerTokenHandler that assigns the header. This lesson reuses those two types unchanged in shape; only the credential the provider sends differs between a client secret, a certificate assertion and a workload identity token.

Managed and workload identities

On a cloud platform the best secret is none. Azure managed identity, Kubernetes workload identity federation (Entra ID, AWS IRSA or Pod Identity, GCP Workload Identity) and similar features give the pod an identity the platform vouches for; the SDK exchanges it for a token, and there is no client secret to store, leak or rotate. In code this is a credential object such as DefaultAzureCredential; the API side is unchanged.

User context: three options

ApproachHowTrade-off
Forward the user's tokenPass the incoming bearer token onOnly works if the audience includes both APIs; broad audiences weaken every API
Token exchangeOn-behalf-of (Entra) or RFC 8693 exchange for a token with the downstream audienceCorrect user identity end to end; one more token call, cache per user
Service identity + user idClient credentials token, user id in the requestSimple; the downstream API trusts the caller to have checked the user

Most internal calls use the third option for jobs and the second where the downstream service must enforce per-user rules itself.

mTLS

Mutual TLS authenticates both ends of the connection with certificates. A service mesh (Istio, Linkerd) does it transparently between sidecars, which secures transport and gives each workload an identity, but the app still needs tokens for authorization decisions. Kestrel can require client certificates itself (ClientCertificateMode.RequireCertificate), which suits a few partner connections; certificate issuance and rotation are then your problem.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Let the orders API reserve stock in the inventory API from a background job with a cached client credentials token.
  • Remove the inventory client secret from Kubernetes Secrets by switching the orders pods to workload identity.
  • Have the invoices API enforce per-customer rules on calls from the orders API by exchanging the user's token on-behalf-of.

More examples

Receiving side: accept only service tokens with the right role

The receiving API uses the same JWT bearer setup as for users; only the policy changes. Granting the orders service an application role (<code>Inventory.Reserve</code>) rather than relying on "any valid token from our tenant" means a compromised reporting service cannot reserve stock. Rejecting tokens that carry <code>scp</code> keeps delegated user tokens out. Entra ID's <code>idtyp=app</code> claim would be clearer, but it appears only after you add <code>idtyp</code> as an optional access-token claim on the inventory API's app registration; requiring it on a default registration returns 403 for every service. Claim names vary by provider, so check a real token before writing the policy.

Example · csharp
using Microsoft.AspNetCore.Authentication.JwtBearer;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"];
        options.Audience = "api://inventory-api";
        options.MapInboundClaims = false;
    });

builder.Services.AddAuthorizationBuilder()
    // Entra ID puts application permissions in "roles". An app-only token has no "scp" claim;
    // idtyp=app is only emitted when added as an optional claim on the app registration.
    .AddPolicy("reserve-stock", policy => policy.RequireAssertion(ctx =>
        ctx.User.HasClaim("roles", "Inventory.Reserve") && !ctx.User.HasClaim(c => c.Type == "scp")));

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

app.MapPost("/stock/{sku}/reservations", (string sku, ReservationRequest body) =>
        TypedResults.Ok(new { sku, body.OrderId, body.Quantity, reserved = true }))
    .RequireAuthorization("reserve-stock");

app.Run();

record ReservationRequest(Guid OrderId, int Quantity);

Workload identity: a platform token instead of a client secret

Only the inside of <code>TokenProvider</code> changes from the client secret version; the handler, the <code>identity</code> client and the registrations are the ones from the Delegating Handlers lesson. This is what the cloud SDK credentials do under the hood with workload identity federation: the cluster signs a short-lived token for the pod's service account, and the provider accepts it as a <code>client_assertion</code> because a federated credential trusts that issuer and subject. Nothing secret is stored in configuration, so there is nothing to rotate or leak. In practice use the provider's SDK credential, which adds the same caching; the <code>.default</code> scope asks for every application permission granted to this identity on the target API.

Example · csharp
using System.Net.Http.Headers;
using System.Text.Json.Serialization;

var builder = WebApplication.CreateBuilder(args);

// Env vars injected by the workload identity webhook: AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_FEDERATED_TOKEN_FILE.
var tenant = builder.Configuration["AZURE_TENANT_ID"];

// Same shape as the client secret version: only the credential inside TokenProvider changes.
builder.Services.AddSingleton<TokenProvider>();
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient("identity", http => http.BaseAddress = new Uri($"https://login.microsoftonline.com/{tenant}/"));
builder.Services.AddHttpClient("inventory", http => http.BaseAddress = new Uri("https://inventory.internal.example.com/"))
    .AddHttpMessageHandler<BearerTokenHandler>();

var app = builder.Build();
app.MapPost("/orders/{id:guid}/reserve", async (Guid id, IHttpClientFactory clients, CancellationToken ct) =>
{
    using var response = await clients.CreateClient("inventory")
        .PostAsJsonAsync("stock/KETTLE-BLUE-1L/reservations", new { orderId = id, quantity = 1 }, ct);
    return Results.StatusCode((int)response.StatusCode);
});
app.Run();

public sealed class BearerTokenHandler(TokenProvider tokens) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
    {
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await tokens.GetTokenAsync(ct));
        return await base.SendAsync(request, ct);
    }
}

public sealed class TokenProvider(IHttpClientFactory factory, IConfiguration config, TimeProvider? clock = null)
{
    private const string Scope = "api://inventory-api/.default";
    private readonly TimeProvider time = clock ?? TimeProvider.System;
    private readonly SemaphoreSlim refreshLock = new(1, 1);
    private string? token;
    private DateTimeOffset refreshAt;

    public async ValueTask<string> GetTokenAsync(CancellationToken ct)
    {
        if (token is not null && time.GetUtcNow() < refreshAt) return token;

        await refreshLock.WaitAsync(ct);
        try
        {
            if (token is not null && time.GetUtcNow() < refreshAt) return token;

            // Re-read the projected token on every refresh: kubelet rotates the file.
            var assertion = await File.ReadAllTextAsync(config["AZURE_FEDERATED_TOKEN_FILE"]!, ct);
            using var response = await factory.CreateClient("identity").PostAsync("oauth2/v2.0/token",
                new FormUrlEncodedContent(new Dictionary<string, string>
                {
                    ["grant_type"] = "client_credentials",
                    ["client_id"] = config["AZURE_CLIENT_ID"]!,
                    ["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
                    ["client_assertion"] = assertion.Trim(),
                    ["scope"] = Scope,
                }), ct);
            response.EnsureSuccessStatusCode();
            var issued = (await response.Content.ReadFromJsonAsync<TokenResponse>(ct))!;

            token = issued.AccessToken;
            refreshAt = time.GetUtcNow().AddSeconds(issued.ExpiresIn - 300);
            return token;
        }
        finally
        {
            refreshLock.Release();
        }
    }
}

public sealed record TokenResponse(
    [property: JsonPropertyName("access_token")] string AccessToken,
    [property: JsonPropertyName("expires_in")] int ExpiresIn);

Requiring client certificates in Kestrel

The TLS handshake fails before any HTTP request is read when the client has no acceptable certificate, so nothing unauthenticated reaches the app. This only works when Kestrel terminates TLS itself; behind a TLS-terminating load balancer the certificate never arrives, and you must use the proxy's mTLS feature and a forwarded header. Pin SHA-256 thumbprints or a private CA, and plan rotation: a partner certificate that expires on a Friday night is a classic outage.

Example · csharp
using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Server.Kestrel.Https;

var builder = WebApplication.CreateBuilder(args);

// Thumbprints of the partner certificates this endpoint accepts, from configuration.
var allowed = builder.Configuration.GetSection("Mtls:AllowedThumbprints").Get<string[]>() ?? [];

builder.WebHost.ConfigureKestrel(kestrel =>
{
    kestrel.ConfigureHttpsDefaults(https =>
    {
        https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
        https.ClientCertificateValidation = (certificate, chain, errors) =>
            errors == System.Net.Security.SslPolicyErrors.None
            && allowed.Contains(certificate.GetCertHashString(System.Security.Cryptography.HashAlgorithmName.SHA256), StringComparer.OrdinalIgnoreCase);
    });
});

var app = builder.Build();

app.MapPost("/partner/invoices", (HttpContext context, InvoiceUpload upload) =>
{
    X509Certificate2? certificate = context.Connection.ClientCertificate;
    return TypedResults.Ok(new { received = upload.Number, from = certificate?.GetNameInfo(X509NameType.SimpleName, forIssuer: false) });
});

app.Run();

record InvoiceUpload(string Number, decimal Amount);

Discussion

  • Be the first to comment on this lesson.