ASP.NET Core Web API Multi-Tenant JWTs
Authentication via a JWT is pretty much standard practice these days and there are lots of blog posts and sample code showing how to do this in ASP.NET Core. However, what if we are implementing a multi-tenant API and want the JWT signing key secret to be different for each tenant? In this post we go through how to implement a multi-tenant JWT.
JWT Generation
First we need an end point that is going to give us a token in exchange for valid credentials. Below is a controller that gives us a good start, validating the user credentials and also the API key. Note that we use the API key to determine the tenant. We’ll come back to this code later to implement the code that generates the JWT.
[Route("api/auth")]
public class AuthController : Controller
{
private readonly IConfiguration _configuration;
private readonly Tenants _tenants;
public AuthController(IConfiguration configuration, Tenants tenants)
{
_configuration = configuration;
_tenants = tenants;
}
[AllowAnonymous]
[HttpPost]
public IActionResult RequestToken([FromBody] UserCredentials userCredentials)
{
// Check the API key is correct
Tenant tenant = null;
string requestAPIKey = Request.Headers["x-api-key"].FirstOrDefault();
if (!string.IsNullOrEmpty(requestAPIKey))
{
tenant = _tenants.Where(t => t.APIKey.ToLower() == requestAPIKey.ToLower()).FirstOrDefault();
}
if (tenant == null)
{
return Unauthorized();
}
// Check the user credentials and return a 400 if they are invalid
if (!ValidateCredentials(userCredentials))
{
return BadRequest();
}
// TODO - generate and return the token ...
}
private bool ValidateCredentials(UserCredentials credentials)
{
// Not for production !!!
return true;
}
}
Below is our tenant model with it’s associated API key and a secret key we need to use in the JWT signing process:
public class Tenant
{
public string TenantId { get; set; }
public string SecretKey { get; set; }
public string APIKey { get; set; }
}
We also have a Tenants
class that gives us some hardcoded tenants for testing.
public class Tenants: List<Tenant>
{
public Tenants()
{
Add(new Tenant { TenantId = "55EC325B-4993-4421-84A6-3312FE7D26CF", SecretKey = "7BD9CDBC-B3C1-47E1-88F4-DEC98A2F7403", APIKey = "4C4505CF-C658-4D69-A2D7-A027DCAE749E" });
Add(new Tenant { TenantId = "C2D79825-9F57-4C38-8FE5-676FC49D0C93", SecretKey = "B270D88D-342E-49F8-8FB5-5DA1613EF1EE", APIKey = "FA9279AB-1BB3-4F0B-999F-51697CB4031F" });
Add(new Tenant { TenantId = "6572D148-6E31-405A-8D6E-E69F69A0A2AF", SecretKey = "A520D71F-E8E4-40E4-BC4A-E74F4101BAB8", APIKey = "4837CD06-4A13-462D-88D0-E042F47501FB" });
}
}
So, let’s do the JWT generatation now. We retreive the issuer
and expiry duration from appsettings.json
. We set the audience
and something called a kid
to the API key. So, what’s the kid
? it’s a hint indicating which key was used to secure the JWS. The kid
will play a crucial part in our code to validate the JWT.
public IActionResult RequestToken([FromBody] UserCredentials userCredentials)
{
// Check the API key is correct
...
// Check the user credentials and return a 400 if they are invalid
...
// Get the token config from appsettings.json
string issuer = _configuration["Token:Issuer"];
int expiryDuration;
if (!int.TryParse(_configuration["Token:ExpiryDurationMins"], out expiryDuration))
{
expiryDuration = 30;
}
DateTime expiry = DateTime.UtcNow.Add(TimeSpan.FromMinutes(expiryDuration));
// Create and sign the token
var jwtSecurityToken = new JwtSecurityToken(
issuer: issuer,
audience: requestAPIKey,
claims: new[]
{
new Claim(ClaimTypes.Name, userCredentials.UserName)
},
expires: expiry,
signingCredentials: new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(tenant.SecretKey)),
SecurityAlgorithms.HmacSha256
)
);
jwtSecurityToken.Header.Add("kid", requestAPIKey);
var token = new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken);
// return the token and when it expires
return Ok(new
{
AccessToken = token,
Expiry = expiry
});
}
With our endpoint in place, we can now generate a multi-tenant JWT:
Authentication
On to the code that validates the JWT …
First we switch JWT authentication on in the Startup
class.
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped<Tenants>();
// Get access to the tenants
var tenants = services.BuildServiceProvider().GetService<Tenants>();
// Setup the JWT authentication
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
// TODO - specify how to validate the JWT
};
});
services.AddMvc();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
// Turn on authentication
app.UseAuthentication();
app.UseMvc();
}
Now comes the interesting part …
We specify that we want to check the issuer, audience, lifetime as well as the signing key. We specify the correct issuer from appsettings.json
. We then specify the valid audiences from the tenant API keys. The most interesting bit (and the bit that took me a while to figure out) is how the signing key is checked. We use the IssuerSigningKeyResolver
delegate. Notice that we pass the kid
from the JWT into the delegate. The delegate finds the tenant from kid (our API key) and uses it’s secret key to produce the signing key.
...
// Setup the JWT authentication
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
// Specify what in the JWT needs to be checked
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
// Specify the valid issue from appsettings.json
ValidIssuer = Configuration["Token:Issuer"],
// Specify the tenant API keys as the valid audiences
ValidAudiences = tenants.Select(t => t.APIKey).ToList(),
IssuerSigningKeyResolver = (string token, SecurityToken securityToken, string kid, TokenValidationParameters validationParameters) =>
{
Tenant tenant = tenants.Where(t => t.APIKey == kid).FirstOrDefault();
List<SecurityKey> keys = new List<SecurityKey>();
if (tenant != null)
{
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(tenant.SecretKey));
keys.Add(signingKey);
}
return keys;
}
};
});
...
Checking an Authorised Endpoint
Let’s implement a nice little authorised endpoint to test all this:
[Authorize]
[Route("api/contacts")]
public class ContactController : Controller
{
[HttpGet("{id}")]
public IActionResult GetById(string id)
{
return Ok(new { Id = id, FirstName = "Fred", Surname = "Smith" });
}
}
If we try it without the Authorization
HTTP header we should get a 401:
If we try with the token we generated earlier, we get access to the data:
Conclusion
The key bit to implementing a multi-tenant JWT in ASP.NET core is using the kid
to identify the tenant. We simply include it in the JWT header during generation and then use the IssuerSigningKeyResolver
delegate to check it during the JWT validation process.
Comments
Ieuan Walker April 11, 2018
Great post, but will you be doing a follow up on how to add roles to different secret keys?
Monsignor April 13, 2018
If I am a tenant in the system and I somehow learned the ApiKey of some other tenant I can use my credentials to access that tenants data. Because all I need is to pass that key in the x-api-key header during retrieval of the token. Am I right?
Carl April 14, 2018
Good question Monsignor, In addition to the API key you will need to pass valid user credentials in the HTTP body and the user credentials are per tenant. So, if I manage to get hold of another tenant’s API key, I can’t get access to the tenants data unless I have valid credentials for that tenant.
Monsignor April 16, 2018
User credentials are per tenant According to the RequestToken method the user credentials are checked before the API key is even read from the headers. I don’t see a second check that would ensure the credentials match that API key. I have an impression that any credentials of any tenant would satisfy the RequestToken method.
Carl April 17, 2018
Good spot Monsignor. Checking the API key should come before checking the user credentials in RequestToken()
ong January 3, 2019
Thanks. I have been searching this for a week. I tried it and it works flawlessly. Keep the good work.
ro January 24, 2019
Would this work in a web farm?
Carl January 24, 2019
Thanks for the question. I can’t think of any reason why it wouldn’t work in a web farm
ro January 28, 2019
Thanks for your answer Carl. I have found an issue here. You set all tenants as valid audience. If you login as, lets say Tenant1, get the token and list customers, everything is fine and you get the customers. But if you take the same token (token generated by Tenant1) and make a request to Tenant2, as Tenant2 is a valid tenant, and the token is a valid token, you get the list of customers of Tenant2! So there should be anywhere a validation where checks that the kid is generated by the same tenant in the current request. Please correct me if I’m wrong! 🙂 Here is my fix :
IssuerSigningKeyResolver = (string token, SecurityToken securityToken, string kid, TokenValidationParameters validationParameters) =>
{
List keys = new List();
var tenantManager = services.BuildServiceProvider().GetRequiredService();
// TODO:CACHÉ
var tokenTenant = tenantManager.GetTenants().FirstOrDefault(t => t.APIKey == kid);
var requestTenant = tenantManager.GetRequestTenant(); // get the current request tenant
if (tokenTenant.Id == requestTenant.Id)
{
gKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(tokenTenant.SecretKey));
keys.Add(signingKey);
}
return keys;
}
Andreas October 25, 2019
How do you handle key rotations? Right now it seems like the app will just stop working (especially if you have RS256 tokens and the issuer rotates key pairs).
Ideally you would want to invoke some public key cache in IssuerSigningKeyResolver, but at that point you don’t have access to the application DI container.
If you to learn about using React with ASP.NET Core you might find my book useful: