In a microservices architecture, secure communication between services is just as important as securing external API calls. One of the most common and robust ways to handle this in .NET is by using IdentityServer, JWT tokens, and the Client Credentials flow.
In this article, you’ll learn how to:
- Authenticate microservices using Client Credentials
- Request and validate JWT tokens
- Pass tokens between services securely
- Optimize token management with caching
- Apply best practices for real-world deployments.
Why Client Credentials Flow?
The Client Credentials flow is designed for machine-to-machine communication. Unlike user-based authentication (e.g., Authorization Code flow), there’s no interactive login. Instead, each service identifies itself using a ClientId and ClientSecret, and IdentityServer issues an access token that can be used to call other services.
When to use it:
- Service A needs to call Service B directly.
- No user context is involved.
- Both services are trusted and run in a secure environment.
Trusted Services and Secure Environment
When we say "both services are trusted and run in a secure environment", it means:
Trusted Services
- Both microservices are owned and controlled by the same organization (or within a trusted partner ecosystem).
- Each service is registered in IdentityServer with its own ClientId and ClientSecret.
- Services rely on IdentityServer as the trust broker — if a service presents a valid JWT from IdentityServer, other services trust it.
Example:
A Billing API (Service A) calls a Payment Processor API (Service B) within your company's internal infrastructure. Both are part of your platform and use IdentityServer for token issuance.
Secure Environment
- Services communicate over a private and encrypted network (e.g., AWS VPC, Azure VNet, Kubernetes internal network).
- All traffic is secured with TLS (HTTPS) to prevent token interception.
- Secrets (e.g., ClientSecret) are stored securely in tools like Azure Key Vault or AWS Secrets Manager.
- IdentityServer is either private or protected with firewalls, API gateways, and access controls.
Example:
Your services are deployed in Kubernetes and communicate over an internal service mesh (like Istio), where mTLS enforces encryption and mutual authentication.
⚠️ If your services are NOT in a trusted or secure environment (e.g., exposed to the public internet or involve third-party APIs), you should:
- Validate the audience in the JWT token strictly.
- Use an API Gateway with rate limiting, throttling, and authentication enforcement.
- Consider mTLS in addition to JWT for an extra security layer.
- Implement scopes and claims-based authorization to enforce least privilege.
Token Flow Diagram
Here’s how authentication works between two microservices:
+-----------------+ +---------------------+ +-----------------+
| Service A | | IdentityServer | | Service B |
| (Caller) | | (Auth Provider) | | (Resource API) |
+-----------------+ +---------------------+ +-----------------+
| | |
| 1. Request Token | |
|----------------------------->| |
| | |
| 2. JWT Token | |
|<-----------------------------| |
| | |
| 3. Call API with Token | |
|------------------------------------------------------------->|
| | |
| 4. Validate Token |
| | |
|<-------------------------------------------------------------|
IdentityServer Setup
First, configure a client in IdentityServer for service-to-service communication:
new Client
{
ClientId = "service-a",
ClientSecrets = { new Secret("secret".Sha256()) },
AllowedGrantTypes = GrantTypes.ClientCredentials,
AllowedScopes = { "service-b-api" }
}
This allows Service A to request a token for the scope service-b-api and then use it when calling Service B.
Requesting a Token in .NET
Service A will use the IdentityModel library to request a token:
using IdentityModel.Client;
using System.Net.Http;
public class TokenService
{
private readonly HttpClient _httpClient;
public TokenService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<string> GetTokenAsync()
{
var disco = await _httpClient.GetDiscoveryDocumentAsync("https://identityserver-url");
if (disco.IsError) throw new Exception(disco.Error);
var tokenResponse = await _httpClient.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
{
Address = disco.TokenEndpoint,
ClientId = "service-a",
ClientSecret = "secret",
Scope = "service-b-api"
});
if (tokenResponse.IsError) throw new Exception(tokenResponse.Error);
return tokenResponse.AccessToken;
}
}
Calling Another Microservice with JWT
Once we have a token, we can attach it to the Authorization header:
using IdentityModel.Client;
public class ServiceBClient
{
private readonly HttpClient _httpClient;
private readonly TokenService _tokenService;
public ServiceBClient(HttpClient httpClient, TokenService tokenService)
{
_httpClient = httpClient;
_tokenService = tokenService;
}
public async Task<string> GetDataAsync()
{
var token = await _tokenService.GetTokenAsync();
_httpClient.SetBearerToken(token);
var response = await _httpClient.GetAsync("https://service-b/api/data");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}
Validating JWT in the Receiving Service
Service B must validate the JWT token. Add JWT Bearer authentication:
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "https://identityserver-url";
options.RequireHttpsMetadata = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false
};
});
builder.Services.AddAuthorization();
Apply authentication and authorization middleware:
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Optimizing with Token Caching
You should avoid requesting a token for every call. Instead, cache it until it expires:
public class CachedTokenService
{
private readonly TokenService _tokenService;
private string _cachedToken;
private DateTime _expiresAt;
public CachedTokenService(TokenService tokenService)
{
_tokenService = tokenService;
}
public async Task<string> GetTokenAsync()
{
if (!string.IsNullOrEmpty(_cachedToken) && DateTime.UtcNow < _expiresAt)
return _cachedToken;
var token = await _tokenService.GetTokenAsync();
_cachedToken = token;
_expiresAt = DateTime.UtcNow.AddMinutes(50); // assuming 1-hour expiry
return _cachedToken;
}
}
Automating Token Injection with DelegatingHandler
You can also create a custom DelegatingHandler to automatically add tokens:
public class TokenHandler : DelegatingHandler
{
private readonly CachedTokenService _tokenService;
public TokenHandler(CachedTokenService tokenService)
{
_tokenService = tokenService;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var token = await _tokenService.GetTokenAsync();
request.SetBearerToken(token);
return await base.SendAsync(request, cancellationToken);
}
}
Register it in Program.cs:
builder.Services.AddHttpClient<ServiceBClient>()
.AddHttpMessageHandler<TokenHandler>();
Now, every call to Service B will automatically include a valid JWT.
Best Practices for Production
🔒 Secure Secrets: Store ClientSecret in a secret manager (e.g., Azure Key Vault or AWS Secrets Manager).
📜 Logging & Monitoring: Log token acquisition failures and monitor IdentityServer availability.
🚀 Use Delegating Handlers: Automate token injection for all service-to-service calls.
Summary
Use IdentityServer and Client Credentials flow for secure microservice-to-microservice communication.
- Retrieve JWT tokens using IdentityModel.
- Validate tokens in each service using JwtBearer.
- Optimize with token caching and DelegatingHandler.
- Follow security best practices for secret management and logging.
By implementing this approach, you ensure that your microservices communicate securely and efficiently in a production environment.
Top comments (0)