Coding Stephan

Validate JWT with OpenID Connect

Working with API’s, or calling the Microsoft Graph API, you’ve probably seen them those JSON Web Tokens (or JWT for short). It’s a way to securely transmit information between parties. But how do you validate them? In this post, I’ll show you how to validate a JWT using OpenID Connect.

JWT title screen

What is a JWT

A JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. A JWT consists of three parts separated by dots (.):

  • Header, which contains the type of token and the signing algorithm.
  • Payload, which contains the claims. Claims are statements about an entity (typically, the user and the application) and data to specify the lifetime, the issuer and the intended audience of the token.
  • Signature, which is used to verify that the sender of the JWT is who it says it is and to ensure that the message wasn’t changed along the way.

What is OpenID Connect

OpenID Connect (OIDC) is an authentication layer on top of OAuth 2.0, an authorization framework. The standard is controlled by the OpenID Foundation. It allows clients to verify the identity of the end-user based on the authentication performed by an authorization server, as well as to obtain basic profile information about the end-user in an interoperable and REST-like manner.

Let me explain why I’m talking about OIDC, when we only want to verify if a token is valid. In the OIDC specification, there is a big part on distributing metadata about the IDP (the application giving out tokens). This metadata contains details about the IDP and it also has the public keys it uses to sign the JWT’s. This is the part we are interested in. With these public keys, we can verify that the token is not modified along the way and that the IDP stands behind the claims in the token.

Metadata

If you know the identifier of the IDP, you can get the metadata from the /.well-known/openid-configuration endpoint. The rest of this post will use the metadata from the Microsoft Identity Platform, but this should work with any OIDC compliant IDP.

DescriptionURL
Microsoft IDP (all accounts)https://login.microsoftonline.com/common/v2.0
Microsoft IDP (only work or school accounts)https://login.microsoftonline.com/organizations/v2.0
Microsoft IDP (only personal accounts)https://login.microsoftonline.com/consumers/v2.0
Microsoft IDP (specific tenant)https://login.microsoftonline.com/df68aa03-48eb-4b09-9f3e-8aecc58e207c/v2.0

In the last url you’ll have to replace df68aa03-48eb-4b09-9f3e-8aecc58e207c with the tenant id of the IDP you want to use. And funny enough, you can also replace this with one of the registered domains for some company. If you get a response know they are using Azure/Microsoft 365 and what their tenant id is.

You can take any of these URL’s and append /.well-known/openid-configuration to get the metadata.

{
    "token_endpoint": "https://login.microsoftonline.com/organizations/oauth2/v2.0/token",
    "token_endpoint_auth_methods_supported": [
        "client_secret_post",
        "private_key_jwt",
        "client_secret_basic"
    ],
    "jwks_uri": "https://login.microsoftonline.com/organizations/discovery/v2.0/keys",
    "response_modes_supported": [
        "query",
        "fragment",
        "form_post"
    ],
    "subject_types_supported": [
        "pairwise"
    ],
    "id_token_signing_alg_values_supported": [
        "RS256"
    ],
    "response_types_supported": [
        "code",
        "id_token",
        "code id_token",
        "id_token token"
    ],
    "scopes_supported": [
        "openid",
        "profile",
        "email",
        "offline_access"
    ],
    "issuer": "https://login.microsoftonline.com/{tenantid}/v2.0",
    ...
}

Public keys

To validate the token, we need the public keys. Currently we are only interested in the jwks_uri property. This is the URL where we can get the public keys. The public keys are in a JSON Web Key Set (JWKS) format. This is a JSON object that represents a set of JWKs. The JSON object MUST have a “keys” member, which is an array of JWKs.

{
    "keys": [
        {
            "kty": "RSA",
            "use": "sig",
            "kid": "kWbkaa6qs8wsTnBwiiNYOhHbnAw",
            "x5t": "kWbkaa6qs8wsTnBwiiNYOhHbnAw",
            "n": "t6Q2XSeWnMA_-crH2UbftfS01QDAqHoPQFqsRtVkxG4eyamnNlTl3Da07QQkjpPEbLoLtgtMI2Pr0plO7xU9f94mhbfK_UJ6Y0KcWxhwKMkCgnzcFOQF4eH_AICHLOKa8vPthtcprNcCmjbksW5TYBZi6uLhFLw_HsjGOxhK0VaDWnWizNVeqvzVB0jt9Vdmfhs6Zohy_1b2Wusdad1NmSKzhC74IDjlIaFoik_ZJJdtLOgoIwOZTLW0M1UKhRrWtj7AjVCnE_zBiloACm1IrIM_PymE10cJJ6WFz29ep4g7X65xCEU6zJ5oIFibvk6cKKcFNB7FFjbehYVpw5BxVQ",
            "e": "AQAB",
            "x5c": [
                "MIIC/TCCAeWgAwIBAgIICHb5qy8hKKgwDQYJKoZIhvcNAQELBQAwLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDAeFw0yNDAxMTUxODA0MTRaFw0yOTAxMTUxODA0MTRaMC0xKzApBgNVBAMTImFjY291bnRzLmFjY2Vzc2NvbnRyb2wud2luZG93cy5uZXQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3pDZdJ5acwD/5ysfZRt+19LTVAMCoeg9AWqxG1WTEbh7Jqac2VOXcNrTtBCSOk8Rsugu2C0wjY+vSmU7vFT1/3iaFt8r9QnpjQpxbGHAoyQKCfNwU5AXh4f8AgIcs4pry8+2G1yms1wKaNuSxblNgFmLq4uEUvD8eyMY7GErRVoNadaLM1V6q/NUHSO31V2Z+GzpmiHL/VvZa6x1p3U2ZIrOELvggOOUhoWiKT9kkl20s6CgjA5lMtbQzVQqFGta2PsCNUKcT/MGKWgAKbUisgz8/KYTXRwknpYXPb16niDtfrnEIRTrMnmggWJu+TpwopwU0HsUWNt6FhWnDkHFVAgMBAAGjITAfMB0GA1UdDgQWBBQLGQYqt7pRrKWQ25XWSi6lGN818DANBgkqhkiG9w0BAQsFAAOCAQEAtky1EYTKZvbTAveLmL3VCi+bJMjY5wyDO4Yulpv0VP1RS3dksmEALOsa1Bfz2BXVpIKPUJLdvFFoFhDqReAqRRqxylhI+oMwTeAsZ1lYCV4hTWDrN/MML9SYyeQ441Xp7xHIzu1ih4rSkNwrsx231GTfzo6dHMsi12oEdyn6mXavWehBDbzVDxbeqR+0ymhCgeYjIfCX6z2SrSMGYiG2hzs/xzypnIPnv6cBMQQDS4sdquoCsvIqJRWmF9ow79oHhzSTwGJj4+jEQi7QMTDR30rYiPTIdE63bnuARdgNF/dqB7n4ZJv566jvbzHpfCTqrJyj7Guvjr9i56NpLmz2DA=="
            ],
            "issuer": "https://login.microsoftonline.com/{tenantid}/v2.0"
        },
        ...
    ]
}

Validate the token already

Okay enough background information, let’s validate some tokens.

Create a new dotnet console application and add the following package Microsoft.IdentityModel.Protocols.OpenIdConnect. This package contains the OpenIdConnectConfiguration class which we will use to get the metadata and the public keys.

// See https://aka.ms/new-console-template for more information
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;

// Add your token here
var token = "...";
// Replace this with the tenant id of the IDP you want to use
var tenantId = "organizations";
// Or change this to some other IDP
var metadataUrl = $"https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration";
var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
    metadataUrl,
    new OpenIdConnectConfigurationRetriever());

// Load the configuration (the configuration manger does some caching, so I would make this a singleton in your DI container)
var config = await configManager.GetConfigurationAsync();

// All openid metadata is now in the config object
Console.WriteLine(config.Issuer);

// Lets use the JwtSecurityTokenHandler to validate the token
var tokenValidatator = new JwtSecurityTokenHandler();
var tokenValidationParameters = new TokenValidationParameters
{
    ValidateIssuerSigningKey = true,
    ValidateLifetime = true,
    ValidateAudience = false,
    IssuerSigningKeys = config.SigningKeys // This is the important part
};

var principal = tokenValidatator.ValidateToken(token, tokenValidationParameters, out var validatedToken);

Validation Guidence

The code above validates the token, but there are some important things to keep in mind when validating tokens.

  • Validate the issuer: The ValidateIssuer property should be set to true and the ValidIssuer property should be set to the issuer of the token. This is the issuer property in the metadata. This is true is you have a single tenant application, but if you have a multi-tenant application, you should set the ValidIssuers property to an array of all the issuers you trust. Or you just trust all the issuers, if that is a better fit for your application.
  • Validate the audience: You should only validate tokens that are for your application. In the Microsoft Identity Platform, the audience is set to the client_id of your application, or the App URI ID of your application.
  • Validate the lifetime: The ValidateLifetime property should be set to true. This will check if the token is not expired. You don’t want to accept tokens that are expired, right?
  • Validate the scopes / roles: You should also check if the token has the correct scopes or roles. This is not done by the JwtSecurityTokenHandler, but should be done by your application by checking the scp or roles claim in the token.

And maybe the most important part, read the documentation of the IDP you are using.

Microsoft Identity platform specific:

Any application in your tenant can get a token for your application! Even if your api requires admin consent, which was not yet given to that application. In this case the the token will have the correct audience and issuer, but it won’t have any roles role or scopes scp claim. Which is why you have to validate these!

In my opinion this is a bad design choice, but it is what it is. I would guess that if an api needs admin consent which is not given, there would be no token issued at all. This would prevent developers from making mistakes by not correctly validating the roles or scopes.

Something phishy with Microsoft Graph

Microsoft strongly suggests that you should not rely on tokens that are not for your application/api. Back in the days you could use this principle to validate tokens that were for first party Microsoft APIs like the Graph API. This is no longer the case, they seem to be doing something with the signature part of the token.

In the Microsoft Identity Platform it’s possible to change the token version on a per app basis. Which means that it will contain some other claims. As a developer you’ll know which accessTokenVersion is selected and validate the token accordingly. This is also the case if you would enable token encryption.

Conclusion

Once you have the metadata with the public keys, you won’t need a network connection or database call to validate the token. This is probably the biggest reason why JWTs are such a hit in the API world.

You can also check out my demo on how protect your dotnet core api with Microsoft Entra repository and demo. It has a code tour that will explain all the steps you have to take in your API.