Skip to main content

Service-to-Service Authentication in .NET: Secure

Service-to-service authentication ensures that only trusted services can communicate with each other. Without it, any service can impersonate any other service, steal data, or trigger unauthorized actions. In a microservices architecture, every inter-service call must be authenticated and authorized. The two most common patterns in .NET are OAuth 2.0 with JWT (JSON Web Tokens) and mutual TLS (mTLS).

OAuth 2.0 is a delegation protocol: Service A requests a token from an authorization server, then includes the token in requests to Service B. Service B validates the token with the authorization server. mTLS uses X.509 certificates: each service has a certificate; services verify each other's certificate before establishing a connection. OAuth is simpler and more common; mTLS is more secure but requires certificate management infrastructure.

OAuth 2.0 Client Credentials Flow

In the client credentials flow, Service A authenticates with an authorization server (e.g., Azure AD, Okta, IdentityServer4) using a client ID and secret, receives a JWT, and includes the token in requests to Service B:

// IdentityServer (authorization server) configuration
public class Config
{
public static IEnumerable<ApiScope> ApiScopes =>
new List<ApiScope>
{
new ApiScope("order-api", "Order API"),
new ApiScope("inventory-api", "Inventory API")
};

public static IEnumerable<Client> Clients =>
new List<Client>
{
// Order Service client
new Client
{
ClientId = "order-service",
ClientSecrets = { new Secret("order-secret-key".Sha256()) },
AllowedGrantTypes = GrantTypes.ClientCredentials,
AllowedScopes = { "inventory-api" } // Order Service can call Inventory API
},

// Notification Service client
new Client
{
ClientId = "notification-service",
ClientSecrets = { new Secret("notification-secret-key".Sha256()) },
AllowedGrantTypes = GrantTypes.ClientCredentials,
AllowedScopes = { "order-api" } // Notification Service can read Orders
}
};
}

Order Service has a client ID (order-service) and secret (order-secret-key). It is authorized to call the Inventory API (scopes: inventory-api).

Service A (Order Service) requests a token:

public class TokenService
{
private readonly HttpClient _httpClient;
private readonly ILogger<TokenService> _logger;
private const string TokenEndpoint = "https://identity-server/connect/token";

public TokenService(HttpClient httpClient, ILogger<TokenService> logger)
{
_httpClient = httpClient;
_logger = logger;
}

public async Task<string> GetAccessTokenAsync(string clientId, string clientSecret)
{
var request = new HttpRequestMessage(HttpMethod.Post, TokenEndpoint);
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "grant_type", "client_credentials" },
{ "client_id", clientId },
{ "client_secret", clientSecret },
{ "scope", "inventory-api" }
});

try
{
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();

var json = await response.Content.ReadAsStringAsync();
var token = JsonSerializer.Deserialize<dynamic>(json);

_logger.LogInformation("Token obtained for client {ClientId}", clientId);
return token!["access_token"];
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to obtain token for client {ClientId}", clientId);
throw;
}
}
}

// Register in DI
builder.Services.AddScoped<TokenService>();

Order Service uses the token to call Inventory Service:

public class InventoryServiceClient
{
private readonly HttpClient _httpClient;
private readonly TokenService _tokenService;
private readonly ILogger<InventoryServiceClient> _logger;

public InventoryServiceClient(HttpClient httpClient, TokenService tokenService, ILogger<InventoryServiceClient> logger)
{
_httpClient = httpClient;
_tokenService = tokenService;
_logger = logger;
}

public async Task<InventoryCheckResult> CheckAvailabilityAsync(List<(int productId, int quantity)> items)
{
// Get a token
var token = await _tokenService.GetAccessTokenAsync("order-service", "order-secret-key");

// Include token in Authorization header
var request = new HttpRequestMessage(HttpMethod.Post, "https://inventory-service/api/check-availability");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Content = JsonContent.Create(new { items });

try
{
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();

var result = await response.Content.ReadAsAsync<InventoryCheckResult>();
return result;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to check inventory");
throw;
}
}
}

Service B (Inventory Service) validates the token:

// Startup configuration
builder.Services
.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "https://identity-server";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidScopes = new[] { "inventory-api" }
};
});

builder.Services.AddAuthorization(options =>
{
options.AddPolicy("InventoryApiScope", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "inventory-api");
});
});

// Controller
[ApiController]
[Route("api")]
[Authorize(Policy = "InventoryApiScope")]
public class InventoryController : ControllerBase
{
[HttpPost("check-availability")]
public async Task<IActionResult> CheckAvailability([FromBody] AvailabilityCheckRequest req)
{
// Token is validated by middleware before this code runs
var result = new InventoryCheckResult { AllAvailable = true };
return Ok(result);
}
}

The [Authorize] attribute validates the JWT. The middleware verifies the signature (using the identity server's public key), checks expiration, and validates scopes. If invalid, the request returns 401 Unauthorized.

Token Caching and Refresh

Requesting a new token for every request is slow. Cache tokens and refresh before expiration:

public class CachedTokenService
{
private readonly TokenService _tokenService;
private readonly IMemoryCache _cache;
private readonly ILogger<CachedTokenService> _logger;

public CachedTokenService(TokenService tokenService, IMemoryCache cache, ILogger<CachedTokenService> logger)
{
_tokenService = tokenService;
_cache = cache;
_logger = logger;
}

public async Task<string> GetAccessTokenAsync(string clientId, string clientSecret)
{
var cacheKey = $"token:{clientId}";

if (_cache.TryGetValue(cacheKey, out string cachedToken))
{
_logger.LogInformation("Using cached token for client {ClientId}", clientId);
return cachedToken;
}

// Get new token
var token = await _tokenService.GetAccessTokenAsync(clientId, clientSecret);

// Cache for 50 minutes (tokens are typically valid for 1 hour; refresh at 50 min)
_cache.Set(cacheKey, token, TimeSpan.FromMinutes(50));

_logger.LogInformation("New token cached for client {ClientId}", clientId);
return token;
}
}

Mutual TLS (mTLS)

mTLS requires each service to present a certificate. Services verify each other's certificate before communication:

// Generate certificates (in production, use a CA like Let's Encrypt or your internal PKI)
// openssl req -new -newkey rsa:2048 -nodes -keyout order-service.key -out order-service.csr
// openssl x509 -req -days 365 -in order-service.csr -signkey order-service.key -out order-service.crt

// Order Service configuration
var handler = new HttpClientHandler();
handler.ClientCertificateOptions = ClientCertificateOption.Manual;

var cert = new X509Certificate2("order-service.pfx", "password");
handler.ClientCertificates.Add(cert);

handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) =>
{
// Validate the server certificate (Inventory Service's cert)
var expectedThumbprint = "inventory-service-cert-thumbprint";
return cert.Thumbprint == expectedThumbprint;
};

var httpClient = new HttpClient(handler);

With mTLS, the TLS handshake verifies certificates. If a rogue service tries to impersonate Inventory Service without a valid certificate, the connection fails immediately.

mTLS vs OAuth:

  • OAuth: Simpler, token-based, works across the internet, requires an identity server.
  • mTLS: Certificate-based, works on-premises or in a private network (Kubernetes cluster), more complex certificate management.

For on-premises microservices in Kubernetes, mTLS (via Istio or Linkerd) is common. For distributed services across regions, OAuth is simpler.

Key Takeaways

  • Use OAuth 2.0 client credentials flow for simple, scalable service-to-service authentication.
  • Store client secrets securely (use Azure Key Vault, AWS Secrets Manager, or equivalent).
  • Cache tokens to reduce authorization server load; refresh before expiration.
  • Use mTLS for additional security in on-premises or Kubernetes clusters.
  • Always validate tokens and enforce scopes (authorization).

Frequently Asked Questions

What is the difference between authentication and authorization?

Authentication verifies identity (is this really the Order Service?). Authorization verifies permissions (is the Order Service allowed to call this endpoint?). A token proves authentication; scopes in the token define authorization.

Should I use a public identity provider or build my own?

For production, use a managed provider (Azure AD, Okta) if available. Building your own (IdentityServer4) is feasible for internal microservices but requires careful security: store secrets securely, use HTTPS, rotate keys regularly, audit access. Most teams use managed providers to avoid this burden.

How do I rotate credentials?

Implement a credential rotation strategy: create a new client secret, update services to use it, revoke the old secret after a grace period. For mTLS, rotate certificates using your certificate management tool (Kubernetes secrets, Azure Key Vault). Plan rotations annually or when staff leave the organization.

What if the identity server is down?

If the identity server is unavailable, services cannot obtain new tokens. Cache tokens for as long as acceptable (hours, not days). In Kubernetes, use a service mesh (Istio, Linkerd) to manage mTLS without an identity server; the mesh issues certificates automatically.

Can I use API keys instead of OAuth?

API keys are simpler but less secure: they do not expire, they do not support scopes (fine-grained permissions), and they are easy to leak. Use keys only for public APIs (third-party developers); use OAuth for internal service-to-service communication.

Further Reading