Exchanging Certificates for Tokens
A close look at the OAuth client credentials grant with JWT token assertions
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
/tokenendpoint. -
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:
- As an extension grant type.
- 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 askid,x5tandx5t#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,audandexp. It also defines the meaning of optional claimsnbf,iat, andjti.
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
basencinstead ofbase64because it can produce unpaddedbase64urlencoding, which is the encoding required by the specs and not quite the same as plainbase64.basencis part ofcoreutils. Alternatively, you can usebase64and sometrwrangling 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.