dotnet multiple jwt bearer

From time to time auth topic appears and is always painfull

In this note going to collect some observations and samples

We are going to figure out how to have multiple bearer jwt auth plus api key auth combined in single app

Movies App

Let's pretend we are build an movies service, where users adding and liking movies, aka as simple as:

mkdir movies
cd movies
dotnet new web --exclude-launch-settings

using System.Security.Claims;

var movies = new List<Movie> {
    new("die-hard", "Die Hard", 1988, "user1"),
    new("alien", "Alien", 1979, "user2"),
    new("terminator-2", "Terminator 2: Judgment Day", 1991, "user1"),
    new("rambo", "Rambo", 2008, "user2"),
};

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.MapGet("/", (ClaimsPrincipal user) => new {
    authenticated = user.Identity?.IsAuthenticated,
    scheme = user.Identity?.AuthenticationType,
    user = user.Identity?.Name,
    claims = user.Claims.ToDictionary(c => c.Type, c => c.Value)
});

app.MapGet("/movies", () => movies);

app.MapGet("/movies/{id}", (string id) => movies.FirstOrDefault(m => m.Id == id));

app.MapPost("/movies", (Movie movie) => {
    movies.Add(movie);
    return movie;
}).RequireAuthorization();

app.MapPut("/movies/{id}", (string id, Movie movie) => {
    var index = movies.FindIndex(m => m.Id == id);
    movies[index] = movie;
    return movie;
}).RequireAuthorization();

app.MapDelete("/movies/{id}", (string id) => {
    var index = movies.FindIndex(m => m.Id == id);
    movies.RemoveAt(index);
}).RequireAuthorization();

app.MapPatch("/movies/{id}/likes", (string id) => {
    var index = movies.FindIndex(m => m.Id == id);
    movies[index].Likes++;
    return movies[index];
});

app.Run();

record Movie (string Id, string Title, int Year, string Owner) {
    public int Likes { get; set; } = 0;
}

Notes:

  • as you can see we are not bothering with validations at all and auth yet, this is by intent, just to have something to start from, key goal here is to have some sample app
  • there is no dependencies, so should be repeatable from scratch

As you can imagine usage is:

curl -s http://localhost:5000/movies | jq
curl -s http://localhost:5000/movies/die-hard | jq
curl -s -X PATCH http://localhost:5000/movies/die-hard/likes | jq

Also note how we are using default endpoint to retrieve current user info even so we did not touched auth at all

The key idea here is that it does not matter what and which auth approach we are going to use at the end it should fill current user ClaimsPrincipal

For anonymous request it will be:

{
  "authenticated": false,
  "scheme": null,
  "user": null,
  "claims": {}
}

All examples below will be kind of copy pasta of this demo app

Authentication vs Authorization

Refresher:

  • authentication - are you logged int?
  • authorization - do you have permissions to do that?

JWT token

Before proceeding to JWT Authentication we need somehow create JWT tokens.

Let's pretend we have an auth service that produces them.

For simplicity and local tests here is an console app to do it.

mkdir jwtcli
cd jwtcli
dotnet new console
dotnet add package System.IdentityModel.Tokens.Jwt

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;

var user = "user1";

var handler = new JwtSecurityTokenHandler();

var token = handler.CreateEncodedJwt(new SecurityTokenDescriptor {
  SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECRET_USED_TO_SIGN_OUR_CUSTOM_JWT_TOKENS")), SecurityAlgorithms.HmacSha256Signature),
  Audience = "http://localhost:5000", // is important if target server checks audience
  Expires = DateTime.UtcNow.AddMinutes(10000),
  Issuer = "https://contoso.com", // is important if target server checks issuer
  Subject = new ClaimsIdentity(new[] {
    new Claim(JwtRegisteredClaimNames.Sub, user),  // "sub" is a well known subject identifier type - aka user identifier
    new Claim(ClaimTypes.Name, user) // "unique_name" is a claim dotnet will use for current user identity name
  }),
});

Console.WriteLine(token);

It will give us token like this one:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMSIsInVuaXF1ZV9uYW1lIjoidXNlcjEiLCJuYmYiOjE3MDM5MzE4OTcsImV4cCI6MTcwNDUzMTg5NywiaWF0IjoxNzAzOTMxODk3LCJpc3MiOiJodHRwczovL2NvbnRvc28uY29tIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwIn0.R5IDO3_YSg0pfp7gmhMLl9rQW4xHQzlA-F9YA3luj5I

We can see whats inside and verify signature here:

{
  "sub": "user1",
  "unique_name": "user1",
  "nbf": 1703931897,
  "exp": 1704531897,
  "iat": 1703931897,
  "iss": "https://contoso.com",
  "aud": "http://localhost:5000"
}

I will save it to $token environment variable and use it in bash script samples to keep them small and readable, aka:

export token=$(dotnet run --project jwtcli)
echo $token

Notes:

  • sub is a well known claim for subject, aka user identifier, but later we will see how dot net wants dedicated claim for user name and will "alias" it
  • as a subject we may use whatever we want, from email to phone or like in our case dedicated string identifier

JWT Authentication

Here is the simples ever way to add authentication to your app

mkdir jwtauth
cd jwtauth
dotnet new web --exclude-launch-settings
dotnet add package Microsoft.Identity.Web

using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

var movies = new List<Movie> {
    new("die-hard", "Die Hard", 1988, "user1"),
    new("alien", "Alien", 1979, "user2"),
    new("terminator-2", "Terminator 2: Judgment Day", 1991, "user1"),
    new("rambo", "Rambo", 2008, "user2"),
};

var builder = WebApplication.CreateBuilder(args);

// ADD JWT AUTH
builder.Services.AddAuthorization().AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options => {
    options.TokenValidationParameters = new TokenValidationParameters {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = "https://contoso.com",
        ValidAudience = "http://localhost:5000",
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECRET_USED_TO_SIGN_OUR_CUSTOM_JWT_TOKENS"))
    };
});

var app = builder.Build();

// USE JWT AUTH
app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/", (ClaimsPrincipal user) => new {
    authenticated = user.Identity?.IsAuthenticated,
    scheme = user.Identity?.AuthenticationType,
    user = user.Identity?.Name,
    claims = user.Claims.ToDictionary(c => c.Type, c => c.Value)
});

app.MapGet("/movies", () => movies);

app.MapGet("/movies/{id}", (string id) => movies.FirstOrDefault(m => m.Id == id));

app.MapPost("/movies", (Movie movie) => {
    movies.Add(movie);
    return movie;
}).RequireAuthorization(); // <- REQUIRES AUTH

app.MapPut("/movies/{id}", (string id, Movie movie) => {
    var index = movies.FindIndex(m => m.Id == id);
    movies[index] = movie;
    return movie;
}).RequireAuthorization();

app.MapDelete("/movies/{id}", (string id) => {
    var index = movies.FindIndex(m => m.Id == id);
    movies.RemoveAt(index);
}).RequireAuthorization();

app.MapPatch("/movies/{id}/likes", (string id) => {
    var index = movies.FindIndex(m => m.Id == id);
    movies[index].Likes++;
    return movies[index];
});

app.Run();

record Movie (string Id, string Title, int Year, string Owner) {
    public int Likes { get; set; } = 0;
}

Now here are few tests:

first lets check that we are still returnin nothing for anonymous user

curl -s http://localhost:5000 | jq
{
  "authenticated": false,
  "scheme": null,
  "user": null,
  "claims": {}
}

And now for authenticated (we have saved token to $token)

curl -s http://localhost:5000 -H "Authorization: Bearer $token" | jq
{
  "authenticated": true,
  "scheme": "AuthenticationTypes.Federation",
  "user": "user1",
  "claims": {
    "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": "user1",
    "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "user1",
    "nbf": "1703931897",
    "exp": "1704531897",
    "iat": "1703931897",
    "iss": "https://contoso.com",
    "aud": "http://localhost:5000"
  }
}

Notes:

  • scheme is being set to AuthenticationTypes.Federation rather than Bearer but we do not care at moment
  • nameidentifier claim comes from sub of our token
  • name claim comes from unique_name of our token and is used as current username
  • both sub and unique_name were removed/replaced deep inside auth library

And now lets check that we wired up everything:

# should respond with 401 unauthorized
curl -i -s -X POST http://localhost:5000/movies -H 'Content-Type: application/json' -d '{"id": "contact", "title": "Contact", "year": 1997, "owner": "user1"}'

# should work
curl -s -X POST http://localhost:5000/movies -H 'Content-Type: application/json' -d '{"id": "contact", "title": "Contact", "year": 1997, "owner": "user1"}' -H "Authorization: Bearer $token" | jq

ApiKey auth

Ok, now we are going to do something similar but for api key

Idea behind this:

  • we allowing access from sibling services which know secret api key only
  • we want to add it alone for a next steps when we will combine different auth methods
mkdir apikeyauth
cd apikeyauth
dotnet new web --exclude-launch-settings

Here is the code:

using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;

var movies = new List<Movie> {
    new("die-hard", "Die Hard", 1988, "user1"),
    new("alien", "Alien", 1979, "user2"),
    new("terminator-2", "Terminator 2: Judgment Day", 1991, "user1"),
    new("rambo", "Rambo", 2008, "user2"),
};

var builder = WebApplication.CreateBuilder(args);

// ADD API KEY AUTH
builder.Services.AddAuthorization().AddAuthentication().AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationOptions.Scheme, options => {});

var app = builder.Build();

// USE API KEY AUTH
app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/", (ClaimsPrincipal user) => new {
    authenticated = user.Identity?.IsAuthenticated,
    scheme = user.Identity?.AuthenticationType,
    user = user.Identity?.Name,
    claims = user.Claims.ToDictionary(c => c.Type, c => c.Value)
});

app.MapGet("/movies", () => movies);

app.MapGet("/movies/{id}", (string id) => movies.FirstOrDefault(m => m.Id == id));

app.MapPost("/movies", (Movie movie) => {
    movies.Add(movie);
    return movie;
}).RequireAuthorization();

app.MapPut("/movies/{id}", (string id, Movie movie) => {
    var index = movies.FindIndex(m => m.Id == id);
    movies[index] = movie;
    return movie;
}).RequireAuthorization();

app.MapDelete("/movies/{id}", (string id) => {
    var index = movies.FindIndex(m => m.Id == id);
    movies.RemoveAt(index);
}).RequireAuthorization();

app.MapPatch("/movies/{id}/likes", (string id) => {
    var index = movies.FindIndex(m => m.Id == id);
    movies[index].Likes++;
    return movies[index];
});

app.Run();

record Movie (string Id, string Title, int Year, string Owner) {
    public int Likes { get; set; } = 0;
}

// API KEY AUTH IMPLEMENTATION DETAILS
class ApiKeyAuthenticationOptions: AuthenticationSchemeOptions {
    public static readonly string Scheme = "ApiKey";
    public string Secret { get; set; } = "SecretApiKey";
}

class ApiKeyAuthenticationHandler(IOptionsMonitor<ApiKeyAuthenticationOptions> options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<ApiKeyAuthenticationOptions>(options, logger, encoder) {
    protected override Task<AuthenticateResult> HandleAuthenticateAsync() {
        if (!AuthenticationHeaderValue.TryParse(Request.Headers.Authorization, out var authorization)) {
            return Task.FromResult(AuthenticateResult.Fail("Missing Authorization header"));
        }

        if (authorization.Scheme != ApiKeyAuthenticationOptions.Scheme) {
            return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Scheme"));
        }

        if (authorization.Parameter != options.CurrentValue.Secret) {
            return Task.FromResult(AuthenticateResult.Fail("Invalid ApiKey"));
        }

        var claims = new[] { 
          new Claim(ClaimTypes.Name, "service2service") // the same way as with JWT, we are going to add "unique_name" claim which will be recognized and used by dotnet whenever we want current username
        };
        var identity = new ClaimsIdentity(claims, ApiKeyAuthenticationOptions.Scheme);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, ApiKeyAuthenticationOptions.Scheme);

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

The same way as before we are going to check that neither anonymour nor jwt request are considered authenticated:

curl -s http://localhost:5000/ | jq
curl -s http://localhost:5000/ -H "Authorization: Bearer $token" | jq

in both cases response is:

{
  "authenticated": false,
  "scheme": null,
  "user": null,
  "claims": {}
}

But for api key auth we got our suer

curl -s http://localhost:5000/ -H "Authorization: ApiKey SecretApiKey" | jq
{
  "authenticated": true,
  "scheme": "ApiKey",
  "user": "service2service",
  "claims": {
    "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "service2service"
  }
}

Notes:

  • note that we have only one name claim
  • which is used by dotnet to access current username
  • also now we are in charge of current auth schema
  • just for example we are adding service2service username so it is definitely clear whats going on, but any other claims can be used and we will play with all that little bit later

Combine JWT and ApiKet auth

So here is the question how should we combine both jwt and api key authentication in dotnet

mkdir combinejwtapikey
cd combinejwtapikey
dotnet new web --exclude-launch-settings
dotnet add package Microsoft.Identity.Web

Here is the catch, our first attempt may be something like this:

// ADD BOTH JWT AND API KEY AUTH
builder.Services.AddAuthorization().AddAuthentication()
// JWT
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => {
    options.TokenValidationParameters = new TokenValidationParameters {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = "https://contoso.com",
        ValidAudience = "http://localhost:5000",
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECRET_USED_TO_SIGN_OUR_CUSTOM_JWT_TOKENS"))
    };
})
// API KEY
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationOptions.Scheme, options => {});

Everything will compile and start, but not matter what we will send - we will be treated as anonymous user

Thats because it is now not clear what authentication to use and what to do if both present etc

From one side we can set "default" authentication scheme like so .AddAuthentication("ApiKey") or like so .AddAuthentication("Bearer") but will it actualy solve something?

It will add following behaviour to our application - everywhere in our app whenever we are injecting current user, if api key auth is passent it will be filled, but if we are going to pass jwt - user will still be anonymous, which will be our first WTF

One of workarounds may be to create authorization policy allowing any of schemes, but how about endpoins that supposed to be called for both anonymous and authenticated users?

Found really good article with explanations of how it should be done:

https://damienbod.com/2022/09/19/asp-net-core-api-auth-with-multiple-identity-providers/

In short, we are going to add .AddAuthentication("Unknown"), then .AddJwtBearer(), then .AddApiKey() and then we will add an policy which job is to return scheme name for incoming request, aka:

// ADD BOTH JWT AND API KEY AUTH
builder.Services.AddAuthorization().AddAuthentication("Unknown")
// JWT
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => {
    options.TokenValidationParameters = new TokenValidationParameters {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = "https://contoso.com",
        ValidAudience = "http://localhost:5000",
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECRET_USED_TO_SIGN_OUR_CUSTOM_JWT_TOKENS"))
    };
})
// API KEY
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationOptions.Scheme, options => {})
// WIREUP BOTH AUTHENTICATIONS
.AddPolicyScheme("Unknown", "figure out scheme for incomming request", options => {
    options.ForwardDefault = JwtBearerDefaults.AuthenticationScheme;
    options.ForwardDefaultSelector = context => {
        if (!AuthenticationHeaderValue.TryParse(context.Request.Headers.Authorization, out var authorization)) {
            return null;
        }
        if (authorization.Scheme == JwtBearerDefaults.AuthenticationScheme) {
            return JwtBearerDefaults.AuthenticationScheme;
        }
        if (authorization.Scheme == ApiKeyAuthenticationOptions.Scheme) {
            return ApiKeyAuthenticationOptions.Scheme;
        }
        return null;
    };
});

Note that we still need some schema that will mark request as authenticated or anonymous, thats why we must set ForwardDefault to something, otherwise you will receive The method or operation is not implemented at Microsoft.AspNetCore.Authentication.PolicySchemeHandler.HandleAuthenticateAsync

Here is what I have ended up with:

using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

var movies = new List<Movie> {
    new("die-hard", "Die Hard", 1988, "user1"),
    new("alien", "Alien", 1979, "user2"),
    new("terminator-2", "Terminator 2: Judgment Day", 1991, "user1"),
    new("rambo", "Rambo", 2008, "user2"),
};

var builder = WebApplication.CreateBuilder(args);

// ADD JWT AUTH
builder.Services.AddAuthorization().AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options => {
    options.TokenValidationParameters = new TokenValidationParameters {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = "https://contoso.com",
        ValidAudience = "http://localhost:5000",
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECRET_USED_TO_SIGN_OUR_CUSTOM_JWT_TOKENS"))
    };
});

var app = builder.Build();

// USE JWT AUTH
app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/", (ClaimsPrincipal user) => new {
    authenticated = user.Identity?.IsAuthenticated,
    scheme = user.Identity?.AuthenticationType,
    user = user.Identity?.Name,
    claims = user.Claims.ToDictionary(c => c.Type, c => c.Value)
});

app.MapGet("/movies", () => movies);

app.MapGet("/movies/{id}", (string id) => movies.FirstOrDefault(m => m.Id == id));

app.MapPost("/movies", (Movie movie) => {
    movies.Add(movie);
    return movie;
}).RequireAuthorization(); // <- REQUIRES AUTH

app.MapPut("/movies/{id}", (string id, Movie movie) => {
    var index = movies.FindIndex(m => m.Id == id);
    movies[index] = movie;
    return movie;
}).RequireAuthorization();

app.MapDelete("/movies/{id}", (string id) => {
    var index = movies.FindIndex(m => m.Id == id);
    movies.RemoveAt(index);
}).RequireAuthorization();

app.MapPatch("/movies/{id}/likes", (string id) => {
    var index = movies.FindIndex(m => m.Id == id);
    movies[index].Likes++;
    return movies[index];
});

app.Run();

record Movie (string Id, string Title, int Year, string Owner) {
    public int Likes { get; set; } = 0;
}

And samples for default endpoint:

curl -s http://localhost:5000/ | jq
curl -s http://localhost:5000/ -H "Authorization: Bearer $token" | jq
curl -s http://localhost:5000/ -H "Authorization: ApiKey SecretApiKey" | jq

Note that we are not adding [Athorization] nor .RequireAuthorization(), endpoint is accessible by both anonymous and authenticated users and based on request correct handler is choosen, which then fills current user, profit.

And as you can guess the same is true for endpoints requiring authentication

# should respond with 401 unauthorized
curl -i -s -X POST http://localhost:5000/movies -H 'Content-Type: application/json' -d '{"id": "contact", "title": "Contact", "year": 1997, "owner": "user1"}'

# jwt should work
curl -s -X POST http://localhost:5000/movies -H 'Content-Type: application/json' -d '{"id": "contact", "title": "Contact", "year": 1997, "owner": "user1"}' -H "Authorization: Bearer $token" | jq

# api key should workd
curl -s -X POST http://localhost:5000/movies -H 'Content-Type: application/json' -d '{"id": "predator", "title": "Predator", "year": 1987, "owner": "user1"}' -H "Authorization: ApiKey SecretApiKey" | jq

Combining multiple JWT authorities

Previous step was crusial for this example

What if we have multiple JWT Bearer authentications, or may be we are in the middle of migration, or may be we want to combine our auth service with Azure Active Directory tokens generated for service account to accomplish service to service communications

In all that cases incomming requests will have Authorization: Bearer xxxx with tokens signed by different authorities

By different authorities we may think of Google, Microsoft, Okta, Identity Server and so on, especially OpenId Connect providers will work nicely out of the box

For now, to keep things simple, lets reuse our console app, by changing authority and signing secret, aka:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMiIsInVuaXF1ZV9uYW1lIjoidXNlcjIiLCJuYmYiOjE3MDM5NDQ4MzMsImV4cCI6MTcwNDU0NDgzMywiaWF0IjoxNzAzOTQ0ODMzLCJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwIn0.EboZv59hfZ0HadaxswedxRi2nfz2UUEYnOgEi3xvClk
{
  "sub": "user2",
  "unique_name": "user2",
  "nbf": 1703944833,
  "exp": 1704544833,
  "iat": 1703944833,
  "iss": "https://example.com",
  "aud": "http://localhost:5000"
}

signed by SECOND_SECRET_FOR_JWT_TOKEN_SIGN

because it has only issued at iat claim and has no expirity exp we will be validating it little bit different

mkdir multiplejwt
cd multiplejwt
dotnet new web --exclude-launch-settings
dotnet add package Microsoft.Identity.Web

using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;

var movies = new List<Movie> {
    new("die-hard", "Die Hard", 1988, "user1"),
    new("alien", "Alien", 1979, "user2"),
    new("terminator-2", "Terminator 2: Judgment Day", 1991, "user1"),
    new("rambo", "Rambo", 2008, "user2"),
};

var builder = WebApplication.CreateBuilder(args);

// ADD MULTIPLE JWT
builder.Services.AddAuthorization().AddAuthentication("Unknown")
// FIRST JWT
.AddJwtBearer("FirstJwt", options => {
    options.TokenValidationParameters = new TokenValidationParameters {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = "https://contoso.com",
        ValidAudience = "http://localhost:5000",
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECRET_USED_TO_SIGN_OUR_CUSTOM_JWT_TOKENS"))
    };
})
// SECOND JWT
.AddJwtBearer("SecondJwt", options => {
    options.TokenValidationParameters = new TokenValidationParameters {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = "https://example.com",
        ValidAudience = "http://localhost:5000",
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECOND_SECRET_FOR_JWT_TOKEN_SIGN"))
    };
})
// WIREUP BOTH AUTHENTICATIONS
.AddPolicyScheme("Unknown", "Dynamically depetermine which JWT to use", options => {
  options.ForwardDefault = "FirstJwt"; // fallback
  options.ForwardDefaultSelector = context => {
    if (!AuthenticationHeaderValue.TryParse(context.Request.Headers.Authorization, out var authorization)) {
      return null;
    }

    if (authorization.Scheme != "Bearer") {
      return null;
    }

    var handler = new JwtSecurityTokenHandler();

    if (!handler.CanReadToken(authorization.Parameter)) {
      return null;
    }

    var issuer = handler.ReadJwtToken(authorization.Parameter).Issuer;

    if (issuer == "https://contoso.com") {
      return "FirstJwt";
    }

    if (issuer == "https://example.com") {
      return "SecondJwt";
    }

    return null;
  };
});

var app = builder.Build();

// USE BOTH JWT AND API KEY AUTH
// not how it always stays the same
app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/", (ClaimsPrincipal user) => new {
    authenticated = user.Identity?.IsAuthenticated,
    scheme = user.Identity?.AuthenticationType,
    user = user.Identity?.Name,
    claims = user.Claims.ToDictionary(c => c.Type, c => c.Value)
});

app.MapGet("/movies", () => movies);

app.MapGet("/movies/{id}", (string id) => movies.FirstOrDefault(m => m.Id == id));

app.MapPost("/movies", (Movie movie) => {
    movies.Add(movie);
    return movie;
}).RequireAuthorization(); // <- REQUIRES AUTH

app.MapPut("/movies/{id}", (string id, Movie movie) => {
    var index = movies.FindIndex(m => m.Id == id);
    movies[index] = movie;
    return movie;
}).RequireAuthorization();

app.MapDelete("/movies/{id}", (string id) => {
    var index = movies.FindIndex(m => m.Id == id);
    movies.RemoveAt(index);
}).RequireAuthorization();

app.MapPatch("/movies/{id}/likes", (string id) => {
    var index = movies.FindIndex(m => m.Id == id);
    movies[index].Likes++;
    return movies[index];
});

app.Run();

record Movie (string Id, string Title, int Year, string Owner) {
    public int Likes { get; set; } = 0;
}

# check main endpoint
curl -s http://localhost:5000/ | jq
curl -s http://localhost:5000/ -H "Authorization: Bearer $token" | jq
curl -s http://localhost:5000/ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMiIsInVuaXF1ZV9uYW1lIjoidXNlcjIiLCJuYmYiOjE3MDM5NDQ4MzMsImV4cCI6MTcwNDU0NDgzMywiaWF0IjoxNzAzOTQ0ODMzLCJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwIn0.EboZv59hfZ0HadaxswedxRi2nfz2UUEYnOgEi3xvClk" | jq

# should respond with 401 unauthorized
curl -i -s -X POST http://localhost:5000/movies -H 'Content-Type: application/json' -d '{"id": "contact", "title": "Contact", "year": 1997, "owner": "user1"}'

# jwt should work
curl -s -X POST http://localhost:5000/movies -H 'Content-Type: application/json' -d '{"id": "contact", "title": "Contact", "year": 1997, "owner": "user1"}' -H "Authorization: Bearer $token" | jq

# api key should workd
curl -s -X POST http://localhost:5000/movies -H 'Content-Type: application/json' -d '{"id": "predator", "title": "Predator", "year": 1987, "owner": "user1"}' -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMiIsInVuaXF1ZV9uYW1lIjoidXNlcjIiLCJuYmYiOjE3MDM5NDQ4MzMsImV4cCI6MTcwNDU0NDgzMywiaWF0IjoxNzAzOTQ0ODMzLCJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwIn0.EboZv59hfZ0HadaxswedxRi2nfz2UUEYnOgEi3xvClk" | jq

As you can guess you can combine many jwt and api keys together with this approach.

Authorization

On top of that you may want to add some additional policies that will tune access to your service

Aka require api key authentication for one endpoints and any jwt for another

Or having certain roles or any other claims in token

mkdir policies
cd policies
dotnet new web --exclude-launch-settings
dotnet add package Microsoft.Identity.Web

using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

// ADD MULTIPLE JWT
builder.Services.AddAuthentication("Unknown")
// FIRST JWT
.AddJwtBearer("FirstJwt", options => {
    options.TokenValidationParameters = new TokenValidationParameters {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = "https://contoso.com",
        ValidAudience = "http://localhost:5000",
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECRET_USED_TO_SIGN_OUR_CUSTOM_JWT_TOKENS"))
    };
})
// SECOND JWT
.AddJwtBearer("SecondJwt", options => {
    options.TokenValidationParameters = new TokenValidationParameters {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = "https://example.com",
        ValidAudience = "http://localhost:5000",
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECOND_SECRET_FOR_JWT_TOKEN_SIGN"))
    };
})
// API KEY
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>("ApiKey", options => {})
// WIREUP BOTH AUTHENTICATIONS
.AddPolicyScheme("Unknown", "Dynamically depetermine which auth to use", options => {
  options.ForwardDefault = "ApiKey"; // fallback
  options.ForwardDefaultSelector = context => {
    if (!AuthenticationHeaderValue.TryParse(context.Request.Headers.Authorization, out var authorization)) {
      return null;
    }

    if (authorization.Scheme == "ApiKey") {
      return "ApiKey";
    }

    if (authorization.Scheme != "Bearer") {
      return null;
    }

    var handler = new JwtSecurityTokenHandler();

    if (!handler.CanReadToken(authorization.Parameter)) {
      return null;
    }

    var issuer = handler.ReadJwtToken(authorization.Parameter).Issuer;

    if (issuer == "https://contoso.com") {
      return "FirstJwt";
    }

    if (issuer == "https://example.com") {
      return "SecondJwt";
    }

    return null;
  };
});

// AUTHORIZATION POLICIES
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("AuthenticatedWithAnyScheme", policy => policy.AddAuthenticationSchemes("FirstJwt", "SecondJwt", "ApiKey").RequireAuthenticatedUser())
    .AddPolicy("AuthenticatedWithJwt", policy => policy.AddAuthenticationSchemes("FirstJwt", "SecondJwt").RequireAuthenticatedUser())
    .AddPolicy("AuthenticatedWithApiKey", policy => policy.AddAuthenticationSchemes("ApiKey").RequireAuthenticatedUser());

var app = builder.Build();

// USE BOTH JWT AND API KEY AUTH
app.UseAuthentication();
app.UseAuthorization();

var demo = (ClaimsPrincipal user) => new {
    authenticated = user.Identity?.IsAuthenticated,
    scheme = user.Identity?.AuthenticationType,
    user = user.Identity?.Name,
    claims = user.Claims.ToDictionary(c => c.Type, c => c.Value)
};

app.MapGet("/", demo);
app.MapGet("/any", demo).RequireAuthorization("AuthenticatedWithAnyScheme");
app.MapGet("/jwt", demo).RequireAuthorization("AuthenticatedWithJwt");
app.MapGet("/apikey", demo).RequireAuthorization("AuthenticatedWithApiKey");

app.Run();

// API KEY IMPLEMENTATION DETAILS
class ApiKeyAuthenticationOptions: AuthenticationSchemeOptions {
    public static readonly string Scheme = "ApiKey";
    public string Secret { get; set; } = "SecretApiKey";
}

class ApiKeyAuthenticationHandler(IOptionsMonitor<ApiKeyAuthenticationOptions> options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<ApiKeyAuthenticationOptions>(options, logger, encoder) {
    protected override Task<AuthenticateResult> HandleAuthenticateAsync() {
        if (!AuthenticationHeaderValue.TryParse(Request.Headers.Authorization, out var authorization)) {
            return Task.FromResult(AuthenticateResult.Fail("Missing Authorization header"));
        }

        if (authorization.Scheme != ApiKeyAuthenticationOptions.Scheme) {
            return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Scheme"));
        }

        if (authorization.Parameter != options.CurrentValue.Secret) {
            return Task.FromResult(AuthenticateResult.Fail("Invalid ApiKey"));
        }

        var claims = new[] {
          new Claim(ClaimTypes.Name, "service2service")
        };
        var identity = new ClaimsIdentity(claims, ApiKeyAuthenticationOptions.Scheme);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, ApiKeyAuthenticationOptions.Scheme);

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

As you can guess endpoints will allow requests only via certain authentication

# main endpoint allow everything
curl -s http://localhost:5000/ | jq
curl -s http://localhost:5000/ -H "Authorization: ApiKey SecretApiKey" | jq
curl -s http://localhost:5000/ -H "Authorization: Bearer $token" | jq
curl -s http://localhost:5000/ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMiIsInVuaXF1ZV9uYW1lIjoidXNlcjIiLCJuYmYiOjE3MDM5NDQ4MzMsImV4cCI6MTcwNDU0NDgzMywiaWF0IjoxNzAzOTQ0ODMzLCJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwIn0.EboZv59hfZ0HadaxswedxRi2nfz2UUEYnOgEi3xvClk" | jq

# /any - works the same way, except it does require auth, so anymous request will receive 401
curl -s http://localhost:5000/any -i
# but rest will work
curl -s http://localhost:5000/any -H "Authorization: ApiKey SecretApiKey" | jq
curl -s http://localhost:5000/any -H "Authorization: Bearer $token" | jq

# /jwt - requiers jwt auth
# so this one wont work
curl -s http://localhost:5000/jwt -i
curl -s http://localhost:5000/jwt -H "Authorization: ApiKey SecretApiKey" -i
# and this will
curl -s http://localhost:5000/jwt -H "Authorization: Bearer $token" | jq

# /apikey - wise versa, for apikey
# non api key requests will receive 401
curl -s http://localhost:5000/apikey -i
curl -s http://localhost:5000/apikey -H "Authorization: Bearer $token" -i
# api key will work
curl -s http://localhost:5000/apikey -H "Authorization: ApiKey SecretApiKey" | jq

In token you may want to pass role claim and add it to requirement or do some other fancy things, but in general thats it.

Here are few related notes: