An unnecessarily deep dive

Exchanging Certificates for Tokens

A close look at the OAuth client credentials grant with JWT token assertions

· 8 min read · 1511 words

I have on several occasions had to dive a bit deeper into OAuth specifications. Recently, I wanted to again understand what exactly happens when you use a certificate in an M2M authentication flow with an OAuth 2.0 provider.

I ended up reacquainting myself with the different specifications involved. In this post, I write down things while I still remember them, and share a bit of scripting that performs the entire flow end-to-end.

Note that you should probably not actually implement any of this yourself in production, and instead opt to use a library. This post uses Microsoft Entra ID (previously Azure Active Directory) as the OAuth 2 provider. Some details might be slightly different when using other providers.

Some details on specifications

Feel free to skip this section if you are more interested in the script.

We will construct a JWT assertion token, then exchange it for a bearer token. This involves many distinct but related specifications:

  • RFC 6749: The OAuth 2.0 specification. Among other things, it defines the client credentials grant that we will use, which is essentially just a POST request to the authorization service’s /token endpoint.

  • RFC 7521: The Assertion Framework for OAuth 2.0 Client Authentication and Authorization Grants. This extends OAuth 2.0 by defining “assertions” which can be used in one of two ways:

    1. As an extension grant type.
    2. Instead of a client secret in the client credentials grant. This is what we look at here.
  • RFC 7515: The JSON Web Signature (JWS) specification. Specifies a wire format consisting of a header, a payload, and a signature, each base64-encoded and separated by dots. The header is also called the JOSE header and details how the token was signed. The only mandatory JOSE header is alg, the algorithm used to construct the signature, but the spec specifies a few more optional headers, such as kid, x5t and x5t#S256, which are used to identify the key used to construct the signature.

  • RFC 7519: Defines what JSON Web Tokens (JWTs) are. These are essentially collections of properties called claims. JWTs must be encoded either in the JWS or JWE format. The JWS format is by far the most common out there. In JWS tokens, the JWT claims are encoded as properties of a JSON object in the token’s payload. JWE tokens are encrypted, rather than signed, and not so common.

    Note that people referring to JWTs (or “JWT tokens”) almost always mean signed JWTs, aka JWS-encoded JWTs, i.e. JWTs encoded in the JWS wire format. Some people also say “JWS token”. I do not judge. The specs all apply to both signed and encrypted JWTs.

  • RFC 7523: Specifies what JWT assertions look like, i.e. it defines the JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants. It specifies that a JWT assertion must have the claims iss, sub, aud and exp. It also defines the meaning of optional claims nbf, iat, and jti.

RFC 6750 specifies what you can do with bearer tokens, the result of our exchange.

This post constructs the JWT assertion token from a certificate private key, according to the JWS specification. Note that all the same specs apply in scenarios where we exchange third-party generated JWT tokens as assertions in trust delegation scenarios. RFC 7523 mentions:

The process by which the client obtains the JWT, prior to exchanging it with the authorization server or using it for client authentication, is out of scope.

Most authorization servers nowadays are OpenID providers. These issue JWT ID tokens that can be used as assertions. If you are interested in the trust delegation scenario, you may like this other post:

Obtaining your certificate

We will need a certificate’s public and private keys in PEM format.

If you do not have a certificate pair yet, you can create one as follows:

openssl genrsa -out ca.key 3096
openssl req -x509 -new -nodes -key ca.key \
    -sha256 -days 365 -out ca.crt \
    -subj "/CN=your-common-name/C=BE"

Create an application registration in Entra, and upload the certificate’s public key to it.

The remainder of the post assumes you have the following variables set:

CERT_PATH=/path/to/your/ca.crt
KEY_PATH=/path/to/your/ca.key
CLIENT_ID=<your-entra-application-registration-client-id>
TENANT_ID=<your-entra-tenant-id>

Entra also refers to the client ID as the application ID. If you want to follow along using a different authorization server, you do not need to set TENANT_ID, but should adjust the token endpoint URL in the command below.

Constructing a JWT assertion

We use our certificate’s private key to construct a JWS-encoded JWT “assertion” that proves that we are in possession of the private key, without actually transmitting it. In the next section we exchange this assertion for a JWT bearer token.

Assertion header

Start by constructing the JOSE header, which is a JSON object with certain properties. In addition to the mandatory alg field, Entra also requires at least one way to identify the certificate used for signing. Entra accepts the x5t (X.509 certificate SHA-1 thumbprint) and kid (key ID) properties for this purpose. Note that Entra does not accept x5t#S256 (X.509 certificate SHA-256 thumbprint).

CERT_THUMBPRINT_HEX=$(openssl x509 \
    -in "$CERT_PATH" -fingerprint -sha1 -noout \
    | cut -d= -f2 \
    | tr -d ':')
CERT_THUMBPRINT=$(echo "$CERT_THUMBPRINT_HEX" \
    | xxd -r -p \
    | basenc --base64url)
JWT_HEADER=$(jq -n \
    --arg alg "RS256" \
    --arg typ "JWT" \
    --arg x5t "$CERT_THUMBPRINT" \
    '{
        "alg": $alg,
        "typ": $typ,
        "x5t": $x5t
    }')
JWT_HEADER_B64=$(echo -n "$JWT_HEADER" | basenc --base64url)

The value of CERT_THUMBPRINT_HEX should match the thumbprint listed in Entra.

We use basenc instead of base64 because it can produce unpadded base64url encoding, which is the encoding required by the specs and not quite the same as plain base64. basenc is part of coreutils. Alternatively, you can use base64 and some tr wrangling to produce the same result, e.g.:

CERT_THUMBPRINT=$(echo "$CERT_THUMBPRINT_HEX" \
    | xxd -r -p \
    | base64 \
    | tr '+/' '-_' \
    | tr -d '=')

Assertion payload

The payload is a plain JSON object with certain properties. When using certificate-based authentication, Entra expects the client ID to be both the issuer and the subject of the assertion, and expects your tenant’s token endpoint to be the audience.

CURRENT_TIME=$(date +%s)
EXPIRY_TIME=$((CURRENT_TIME + 3600))  # 1 hour from now

JWT_PAYLOAD=$(jq -n \
    --arg iss "$CLIENT_ID" \
    --arg sub "$CLIENT_ID" \
    --arg aud "https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token" \
    --arg jti "$(uuidgen)" \
    --argjson iat "$CURRENT_TIME" \
    --argjson exp "$EXPIRY_TIME" \
    '{
        "iss": $iss,
        "sub": $sub,
        "aud": $aud,
        "jti": $jti,
        "iat": $iat,
        "exp": $exp
    }')

JWT_PAYLOAD_B64=$(echo -n "$JWT_PAYLOAD" | basenc --base64url)

Assertion signature

Use the private key to sign the assertion. This involves hashing the assertion’s header and payload, and signing the hash with the private key. The details are complicated.

SIGNATURE_INPUT="${JWT_HEADER_B64}.${JWT_PAYLOAD_B64}"
JWT_SIGNATURE=$(echo -n "$SIGNATURE_INPUT" \
| openssl dgst -sha256 -sign "$KEY_PATH" \
| basenc --base64url)

Assemblage

The final assertion is the concatenation of the base64-encoded header, payload, and signature, separated by dots.

CLIENT_ASSERTION="${JWT_HEADER_B64}.${JWT_PAYLOAD_B64}.${JWT_SIGNATURE}"

Using the assertion to request a token

Exchange the assertion for a token using a client credentials grant. This is essentially a single HTTP POST request to the token endpoint.

ENDPOINT="https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token"
RESPONSE=$(curl -X POST "$ENDPOINT" \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "client_id=${CLIENT_ID}" \
    -d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
    -d "client_assertion=${CLIENT_ASSERTION}" \
    -d "scope=https://graph.microsoft.com/.default" \
    -d "grant_type=client_credentials"
)

Change the token endpoint URL if you want to use a different authorization server, and the scope if you are targeting a different API. The response should be a JSON document looking approximately like this:

{
  "token_type": "Bearer",
  "expires_in": 3599,
  "ext_expires_in": 3599,
  "access_token": "an_access_token"
}

In most cases, the authorization server’s bearer tokens will again be JWS-encoded JWTs. This is the case for Entra ID and all other OIDC-compatible authorization servers. You can inspect such bearer tokens further by decoding them as follows:

echo $RESPONSE \
| jq -r '.access_token' \
| tr '.' '\n' `# Split header, payload and signature in different lines` \
| head -n 2 `# Extract header and payload, dropping signature` \
| basenc --base64url -d `# Decode each part into a JSON object` \
| jq . `# Pretty-print the object`

The output should look something like this:

{
  "typ": "JWT",
  "nonce": "Z3u7jBDH_yQwS2O7woGCIP4uGw8KfC_0eGVfEqW28YQ",
  "alg": "RS256",
  "x5t": "HS23b7Do7TcaU1RoLHwpIq24VYg",
  "kid": "HS23b7Do7TcaU1RoLHwpIq24VYg"
}
{
  "aud": "https://graph.microsoft.com",
  "iss": "https://sts.windows.net/<your-tenant-id>/",
  "iat": 1759269606,
  "nbf": 1759269606,
  "exp": 1759273506,
  "aio": "k2JgYHhzwfCZ8299xbsH9eJVD7SeBAA=",
  "app_displayname": "<your-application-name>",
  "appid": "<your-client-id>",
  "appidacr": "2",
  "idp": "https://sts.windows.net/<your-tenant-id>/",
  "idtyp": "app",
  "rh": "1.AUsAayPZGvYxyEyyaOODO5MQqwMAAAAAAAAAwAAAAAAAAAC8AQBLAA.",
  "tenant_region_scope": "EU",
  "tid": "<your-tenant-id>",
  "uti": "EYbOvVn29ke5atO2_KgHAA",
  "ver": "1.0",
  "wids": [
    "0997a1d0-0d1d-4acb-b408-d5ca73121e90"
  ],
  "xms_ftd": "tnOWN3qnpkVQ3NnqLbdlg1wS5edjuyLw-AlxRkNZsakBZXVyb3Bld2VzdC1kc21z",
  "xms_idrel": "13 10",
  "xms_rd": "0.42LlYBJi9BYS4WAXErhd8kgkj_mWQ_8OochJSXlaQFFOIYHpy06XXbJ38dmy7XrX9tM_nIGiHEICzAwQcABKAwA",
  "xms_tcdt": 1733755599,
  "xms_tdbr": "EU"
}

Summary

This post explores some details of the OAuth 2.0 specifications, specifically the parts of it related to JWT assertions and M2M authentication. It briefly presents the relevant RFCs, and presents scripting commands to construct a JWT assertion token and exchange it for a bearer token using a certificate private and public key.