JSON Web Signatures, KIDs and Thumbprints — Sticking to Standards

Christian Brindley
8 min readSep 1, 2020

--

In this article, we are going to explore the world of JSON web signatures (JWS), focusing specifically on how third parties can access and identify your keys in order to verify your signatures.

In a follow up article, we’ll look at a practical standards based approach using the ForgeRock Identity Platform.

Terminology

As a starting point, let’s look at a sample authentication token which uses the OpenID Connect standard (OIDC) to identify an end user.

Raw OIDC token

eyJ0eXAiOiJKV1QiLCJraWQiOiJFRjcxaVNhb3NiQzVDNHRDNlN5cTFHbTY0N00iLCJhbGciOiJQUzI1NiJ9.eyJhdF9oYXNoIjoibEVqbFlqWVgtYTF6djl1VktsODlWUSIsInN1YiI6ImphbmUuZG9lIiwiYXVkaXRUcmFja2luZ0lkIjoiY2IxNThlYjgtOGM4Zi00MGI1LWE0MGUtYTU0NjQyOTI1ZTJhLTI1MiIsImlzcyI6Imh0dHBzOi8vYW0uYXV0aGRlbW8ub3JnL29hdXRoMi9yZWFsbXMvcm9vdC9yZWFsbXMvdGVzdCIsInRva2VuTmFtZSI6ImlkX3Rva2VuIiwiYXVkIjoidGVzdGNsaWVudCIsImF6cCI6InRlc3RjbGllbnQiLCJhdXRoX3RpbWUiOjE1OTgyODg4OTAsInJlYWxtIjoiL3Rlc3QiLCJleHAiOjE1OTgyODk0OTMsInRva2VuVHlwZSI6IkpXVFRva2VuIiwiaWF0IjoxNTk4Mjg4ODkzfQ.NNKNdsOD2h0Y1kz75Ljqluu3QWzgVZyqrOxmBnMI9I6nPAqhd4rkxo3HsQ_E1e_0dpa_jp-xB4-FXk0RLI2xqFp7fEehW9NdaMZm2nT75Id2O_IAoNhqV_iski6HlKSwB3qJ5MwjBS2R2EG_3Co3KDn2NuyIuqpu1RS6Ut1TnYH8P4-jse4AIIRr9kM-Id52-TU1NlKkSAcHvjqyoPhXt6L_6nA60ZtduXWVwkWCuvhH32myG5K8UEQxNU-lfO8L7VAWQPRPDPo1fDqlyMKeWQHlGA8TrgXRbdry1p0JvETFFXE_GlxkOO5MFeOB3HgwftW6Mhf-N9g3Wewx3HMhgQ

In JWx terms, this is a JSON Web Token (JWT) secured by a JSON Web Signature (JWS).

The token consists of three elements, which are each base64url encoded and then concatenated with dot delimiters. If we break down the three elements of our token and decode them, we get the following:

  • The JSON Object Signing and Encryption (JOSE) header. More on this later.
{
"typ": "JWT",
"kid": "EF71iSaosbC5C4tC6Syq1Gm647M",
"alg": "PS256"
}
  • The JOSE payload (i.e. the signed data). In the case of OIDC, this contains details about the identity we have authenticated.
{
"at_hash": "lEjlYjYX-a1zv9uVKl89VQ",
"sub": "jane.doe",
"auditTrackingId": "cb158eb8-8c8f-40b5-a40e-a54642925e2a-252",
"iss": "https://am.authdemo.org/oauth2/realms/root/realms/test",
"tokenName": "id_token",
"aud": "testclient",
"azp": "testclient",
"auth_time": 1598288890,
"realm": "/test",
"exp": 1598289493,
"tokenType": "JWTToken",
"iat": 1598288893
}
  • The JOSE signature computed across the header and payload (binary value when decoded, so not shown here).

The method required to verify the signature depends on the method used to create it. There are two broad types of key used for signature: symmetric and asymmetric.

Symmetric key based signatures use a shared key. You need a copy of the signing key in order to verify the signature. In this case, you need a secure way of sharing the signing key between parties, and you need to trust each party you share the key with.

Asymmetric key based signatures use a private/public key pair. In this case you only need the public key to verify the signature. Since the public key is not sensitive (i.e. you can’t use it to forge signatures), you can freely publish the key to allow anyone to verify your signatures.

We are going to focus on asymmetric keys, because they offer the best security and most flexible deployment options.

Sharing your keys

In order for relying parties to be able to verify your signatures, they need access to your keys. So how do you give them this access?

Firstly, you need a common way to represent your keys. One method is to use the JSON Web Key (JWK) standard. This is a JSON representation of keys used for signing, encryption, and various other purposes. The following is an example JWK with a couple of signing keys — one RSA key and one Elliptic Curve.

Sample JWK document

{
“keys”: [
{
“kty”: “RSA”,
“kid”: “EF71iSaosbC5C4tC6Syq1Gm647M”,
“use”: “sig”,
“x5t”: “5eOfy1Nn2MMIKVRRkq0OgFAw348”,
“x5c”: [
“MIIDdzCCAl+gAwIBAgIES3eb+zANBgkqhkiG9w0BAQsFADBsMRAwDgYDVQQGEwdVbmtub3duMRAwDgYDVQQIEwdVbmtub3duMRAwDgYDVQQHEwdVbmtub3duMRAwDgYDVQQKEwdVbmtub3duMRAwDgYDVQQLEwdVbmtub3duMRAwDgYDVQQDEwdVbmtub3duMB4XDTE2MDUyNDEzNDEzN1oXDTI2MDUyMjEzNDEzN1owbDEQMA4GA1UEBhMHVW5rbm93bjEQMA4GA1UECBMHVW5rbm93bjEQMA4GA1UEBxMHVW5rbm93bjEQMA4GA1UEChMHVW5rbm93bjEQMA4GA1UECxMHVW5rbm93bjEQMA4GA1UEAxMHVW5rbm93bjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANdIhkOZeSHagT9ZecG+QQwWaUsi7OMv1JvpBr/7HtAZEZMDGWrxg/zao6vMd/nyjSOOZ1OxOwjgIfII5+iwl37oOexEH4tIDoCoToVXC5iqiBFz5qnmoLzJ3bF1iMupPFjz8Ac0pDeTwyygVyhv19QcFbzhPdu+p68epSatwoDW5ohIoaLzbf+oOaQsYkmqyJNrmht091XuoVCazNFt+UJqqzTPay95Wj4F7Qrs+LCSTd6xp0Kv9uWG1GsFvS9TE1W6isVosjeVm16FlIPLaNQ4aEJ18w8piDIRWuOTUy4cbXR/Qg6a11l1gWls6PJiBXrOciOACVuGUoNTzztlCUkCAwEAAaMhMB8wHQYDVR0OBBYEFMm4/1hF4WEPYS5gMXRmmH0gs6XjMA0GCSqGSIb3DQEBCwUAA4IBAQDVH/Md9lCQWxbSbie5lPdPLB72F4831glHlaqms7kzAM6IhRjXmd0QTYq3Ey1J88KSDf8A0HUZefhudnFaHmtxFv0SF5VdMUY14bJ9UsxJ5f4oP4CVh57fHK0w+EaKGGIw6TQEkL5L/+5QZZAywKgPz67A3o+uk45aKpF3GaNWjGRWEPqcGkyQ0sIC2o7FUTV+MV1KHDRuBgreRCEpqMoY5XGXe/IJc1EJLFDnsjIOQU1rrUzfM+WP/DigEQTPpkKWHJpouP+LLrGRj2ziYVbBDveP8KtHvLFsnexA/TidjOOxChKSLT9LYFyQqsvUyCagBb4aLs009kbW6inN8zA6”
],
“n”: “10iGQ5l5IdqBP1l5wb5BDBZpSyLs4y_Um-kGv_se0BkRkwMZavGD_Nqjq8x3-fKNI45nU7E7COAh8gjn6LCXfug57EQfi0gOgKhOhVcLmKqIEXPmqeagvMndsXWIy6k8WPPwBzSkN5PDLKBXKG_X1BwVvOE9276nrx6lJq3CgNbmiEihovNt_6g5pCxiSarIk2uaG3T3Ve6hUJrM0W35QmqrNM9rL3laPgXtCuz4sJJN3rGnQq_25YbUawW9L1MTVbqKxWiyN5WbXoWUg8to1DhoQnXzDymIMhFa45NTLhxtdH9CDprXWXWBaWzo8mIFes5yI4AJW4ZSg1PPO2UJSQ”,
“e”: “AQAB”,
“alg”: “PS256”
},
{
“kty”: “EC”,
“kid”: “WhUPrWNhvLWLxtrU3–1KMKn2o8I”,
“use”: “sig”,
“x5t”: “MUOPc5byMEN9q_9gqArkd1EDajg”,
“x5c”: [
“MIIBwjCCAWkCCQCw3GyPBTSiGzAJBgcqhkjOPQQBMGoxCzAJBgNVBAYTAlVLMRAwDgYDVQQIEwdCcmlzdG9sMRAwDgYDVQQHEwdCcmlzdG9sMRIwEAYDVQQKEwlGb3JnZVJvY2sxDzANBgNVBAsTBk9wZW5BTTESMBAGA1UEAxMJZXMyNTZ0ZXN0MB4XDTE3MDIwMzA5MzQ0NloXDTIwMTAzMDA5MzQ0NlowajELMAkGA1UEBhMCVUsxEDAOBgNVBAgTB0JyaXN0b2wxEDAOBgNVBAcTB0JyaXN0b2wxEjAQBgNVBAoTCUZvcmdlUm9jazEPMA0GA1UECxMGT3BlbkFNMRIwEAYDVQQDEwllczI1NnRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ3sy05tV/3YUlPBi9jZm9NVPeuBmntrtcO3NP/1HDsgLsTZsqKHD6KWIeJNRQnONcriWVaIcZYTKNykyCVUz93MAkGByqGSM49BAEDSAAwRQIgZhTox7WpCb9krZMyHfgCzHwfu0FVqaJsO2Nl2ArhCX0CIQC5GgWD5jjCRlIWSEFSDo4DZgoQFXaQkJUSUbJZYpi9dA==”
],
“x”: “N7MtObVf92FJTwYvY2ZvTVT3rgZp7a7XDtzT_9Rw7IA”,
“y”: “uxNmyoocPopYh4k1FCc41yuJZVohxlhMo3KTIJVTP3c”,
“crv”: “P-256”,
“alg”: “ES256”
}
]
}
}}

Secondly, you need a common way to communicate this key information. One option is to publish your JWK online through HTTPS, via a known JWK URI. This allows dynamic changes such as algorithm updates and key rollover, and makes relying party configuration very simple. A common way to advertise your JWK URI is via your OpenID Connect Discovery endpoint.

Key Identifiers

You’ll see from the sample JWK document above that a JWK can include multiple keys, covering different key generations, purposes and algorithms. When verifying the signature on a JWS, there needs to be a way to identify the required key within a JWK.

Step forward the Key Identifier (KID). The JWS can include a signing KID in the"kid"header field, which must match a "kid" entry in your JWK. Revisiting our sample OIDC token, we can see the "kid" parameter in the JOSE header

OIDC token header

{
"typ": "JWT",
"kid": "EF71iSaosbC5C4tC6Syq1Gm647M",
"alg": "PS256"
}

Looking at the key entries in the sample JWK, we can find a matching entry with the same "kid" parameter as follows

JWK key entry

{
“kty”: “RSA”,
“kid”: “EF71iSaosbC5C4tC6Syq1Gm647M”,
“use”: “sig”,
“x5t”: “5eOfy1Nn2MMIKVRRkq0OgFAw348”,
“x5c”: [
“MIIDdzCC...kbW6inN8zA6”
],
“n”: “10iGQ5l5Idq...zDymIMhFa45NTLhxtdH9CDprXWXWBaWzo8mIFes5yI4AJW4ZSg1PPO2UJSQ”,
“e”: “AQAB”,
“alg”: “PS256”
}

We can now extract the public key from this JWK entry and use it to verify the signature on our OIDC token.

Trying it out

We can use openssl to test the end to end process for signature verification. Of course this would normally be performed automatically by the relying party platform.

For a scripted approach, have a look at the sample jwtx script available here.

The steps (using the bash shell on *nix) are as follows

  • Load up the OIDC token
oidc_token='eyJ0eXAiOiJKV1QiLCJraWQiOiJFRjcxaVNhb3NiQzVDNHRDNlN5cTFHbTY0N00iLCJhbGciOiJQUzI1NiJ9.eyJhdF9oYXNoIjoibEVqbFlqWVgtYTF6djl1VktsODlWUSIsInN1YiI6ImphbmUuZG9lIiwiYXVkaXRUcmFja2luZ0lkIjoiY2IxNThlYjgtOGM4Zi00MGI1LWE0MGUtYTU0NjQyOTI1ZTJhLTI1MiIsImlzcyI6Imh0dHBzOi8vYW0uYXV0aGRlbW8ub3JnL29hdXRoMi9yZWFsbXMvcm9vdC9yZWFsbXMvdGVzdCIsInRva2VuTmFtZSI6ImlkX3Rva2VuIiwiYXVkIjoidGVzdGNsaWVudCIsImF6cCI6InRlc3RjbGllbnQiLCJhdXRoX3RpbWUiOjE1OTgyODg4OTAsInJlYWxtIjoiL3Rlc3QiLCJleHAiOjE1OTgyODk0OTMsInRva2VuVHlwZSI6IkpXVFRva2VuIiwiaWF0IjoxNTk4Mjg4ODkzfQ.NNKNdsOD2h0Y1kz75Ljqluu3QWzgVZyqrOxmBnMI9I6nPAqhd4rkxo3HsQ_E1e_0dpa_jp-xB4-FXk0RLI2xqFp7fEehW9NdaMZm2nT75Id2O_IAoNhqV_iski6HlKSwB3qJ5MwjBS2R2EG_3Co3KDn2NuyIuqpu1RS6Ut1TnYH8P4-jse4AIIRr9kM-Id52-TU1NlKkSAcHvjqyoPhXt6L_6nA60ZtduXWVwkWCuvhH32myG5K8UEQxNU-lfO8L7VAWQPRPDPo1fDqlyMKeWQHlGA8TrgXRbdry1p0JvETFFXE_GlxkOO5MFeOB3HgwftW6Mhf-N9g3Wewx3HMhgQ'
  • Extract the OIDC token header and payload (i.e. the first two elements of the token) as the input data for signature verification. Be careful not to add a newline.
awk -F. '{printf("%s.%s",$1,$2)}' <<< "$oidc_token" > signed.data
  • Extract the OIDC token signature (i.e the third element of the token) and convert to binary (openssl doesn’t support base64url decoding, so we have to do a bit of manipulation with sed to convert it to plain base64)
awk -F. '{printf($3)}' <<< "$oidc_token" | sed 's/-/+/g; s/_/\//g; s/$/==/' | openssl base64 -d -A -out signature.bin
  • Search the JWK document for the KID from the token header, and extract the public key from the corresponding certificate (the "x5c" parameter in the JWK). The certificate will be all on one line, so we need to feed it through openssl twice.
echo 'MIIDdzCCAl+gAwIBAgIES3eb+zANBgkqhkiG9w0BAQsFADBsMRAwDgYDVQQGEwdVbmtub3duMRAwDgYDVQQIEwdVbmtub3duMRAwDgYDVQQHEwdVbmtub3duMRAwDgYDVQQKEwdVbmtub3duMRAwDgYDVQQLEwdVbmtub3duMRAwDgYDVQQDEwdVbmtub3duMB4XDTE2MDUyNDEzNDEzN1oXDTI2MDUyMjEzNDEzN1owbDEQMA4GA1UEBhMHVW5rbm93bjEQMA4GA1UECBMHVW5rbm93bjEQMA4GA1UEBxMHVW5rbm93bjEQMA4GA1UEChMHVW5rbm93bjEQMA4GA1UECxMHVW5rbm93bjEQMA4GA1UEAxMHVW5rbm93bjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANdIhkOZeSHagT9ZecG+QQwWaUsi7OMv1JvpBr/7HtAZEZMDGWrxg/zao6vMd/nyjSOOZ1OxOwjgIfII5+iwl37oOexEH4tIDoCoToVXC5iqiBFz5qnmoLzJ3bF1iMupPFjz8Ac0pDeTwyygVyhv19QcFbzhPdu+p68epSatwoDW5ohIoaLzbf+oOaQsYkmqyJNrmht091XuoVCazNFt+UJqqzTPay95Wj4F7Qrs+LCSTd6xp0Kv9uWG1GsFvS9TE1W6isVosjeVm16FlIPLaNQ4aEJ18w8piDIRWuOTUy4cbXR/Qg6a11l1gWls6PJiBXrOciOACVuGUoNTzztlCUkCAwEAAaMhMB8wHQYDVR0OBBYEFMm4/1hF4WEPYS5gMXRmmH0gs6XjMA0GCSqGSIb3DQEBCwUAA4IBAQDVH/Md9lCQWxbSbie5lPdPLB72F4831glHlaqms7kzAM6IhRjXmd0QTYq3Ey1J88KSDf8A0HUZefhudnFaHmtxFv0SF5VdMUY14bJ9UsxJ5f4oP4CVh57fHK0w+EaKGGIw6TQEkL5L/+5QZZAywKgPz67A3o+uk45aKpF3GaNWjGRWEPqcGkyQ0sIC2o7FUTV+MV1KHDRuBgreRCEpqMoY5XGXe/IJc1EJLFDnsjIOQU1rrUzfM+WP/DigEQTPpkKWHJpouP+LLrGRj2ziYVbBDveP8KtHvLFsnexA/TidjOOxChKSLT9LYFyQqsvUyCagBb4aLs009kbW6inN8zA6' | openssl base64 -d -A | openssl x509 -inform der -noout -pubkey > pubkey.pem
  • Finally, verify the signature. Our sample token is signed with the PS256 algorithm, so we need to tell openssl about the padding mode (PSS) and hashing algorithm (SHA256)
openssl dgst -sigopt rsa_padding_mode:pss -verify pubkey.pem -sha256 -signature signature.bin signed.data

All being well, openssl will output the following message:

Verified OK

There are various niceties around using openssl for JWS verification — the jwtx script has more examples.

Standards Based Key Identifiers

Key Identifiers can be any arbitrary value, as long as they are unique within the JWK.

So — if you can use any unique string for your Key Identifier, why would you want to use a standards based algorithm for creating a KID? Why not just use your own method of deriving key IDs, or even just a vanilla UUID?

Well, for one thing, following a known standard ensures that you are using robust, collision-resistant identifiers. Also, and perhaps more crucially, it means that you can agree on a common standard with third parties in cases where you need a consistent approach to Key IDs.

For example, in the case of the UK Open Banking ecosystem, your signing keys are published on your behalf by the Open Banking Directory. The Directory creates and publishes your JWK via a central trusted location. The Key Identifiers used within the JWK are created by the Directory, using the algorithm specified by the RFC 7638 standard.

As an Open Banking participant, when you construct a JWS such as an OIDC token, you need to make sure that the KID in your token header matches the KID in the JWK published by the Directory. Otherwise, the relying party will not be able to find the public key required to verify your signature. The smartest way to ensure your tokens contain the right KIDs is to use the same algorithm as the Directory.

The RFC 7638 Algorithm

Let’s have a look at the RFC 7638 algorithm, in order to understand the requirements for its implementation.

According to the standard, each KID is a “JWK Thumbprint”, based on the details of the key from the JWK. This thumbprint is essentially a hash of a specific subset of JWK fields. The exact subset of fields depends on the key type (Elliptic Curve, RSA or symmetric).

Taking our sample JWK as a working example, we can look at how to build a KID for the key used to sign our OIDC token.

First, we extract the JWK entry for the OIDC signing key. Then we remove all of the parameters except the "e", "kty" and "n" parameters (this is the specified subset for an RSA key). We then put them in alphabetical order, ending up with the following:

{
“e”: “AQAB”,
“kty”: “RSA”,
“n”: “10iGQ5l5IdqBP1l5wb5BDBZpSyLs4y_Um-kGv_se0BkRkwMZavGD_Nqjq8x3-fKNI45nU7E7COAh8gjn6LCXfug57EQfi0gOgKhOhVcLmKqIEXPmqeagvMndsXWIy6k8WPPwBzSkN5PDLKBXKG_X1BwVvOE9276nrx6lJq3CgNbmiEihovNt_6g5pCxiSarIk2uaG3T3Ve6hUJrM0W35QmqrNM9rL3laPgXtCuz4sJJN3rGnQq_25YbUawW9L1MTVbqKxWiyN5WbXoWUg8to1DhoQnXzDymIMhFa45NTLhxtdH9CDprXWXWBaWzo8mIFes5yI4AJW4ZSg1PPO2UJSQ”
}

We then remove all whitespace:

{“e”:“AQAB”,“kty”:“RSA”,“n”:“10iGQ5l5IdqBP1l5wb5BDBZpSyLs4y_Um-kGv_se0BkRkwMZavGD_Nqjq8x3-fKNI45nU7E7COAh8gjn6LCXfug57EQfi0gOgKhOhVcLmKqIEXPmqeagvMndsXWIy6k8WPPwBzSkN5PDLKBXKG_X1BwVvOE9276nrx6lJq3CgNbmiEihovNt_6g5pCxiSarIk2uaG3T3Ve6hUJrM0W35QmqrNM9rL3laPgXtCuz4sJJN3rGnQq_25YbUawW9L1MTVbqKxWiyN5WbXoWUg8to1DhoQnXzDymIMhFa45NTLhxtdH9CDprXWXWBaWzo8mIFes5yI4AJW4ZSg1PPO2UJSQ”}

Finally, we create a hash of the resulting JSON, and base64url encode the hash to get the thumbprint. RFC 7638 does not mandate a specific hashing algorithm, but leaves it up to participants to agree the algorithm among themselves if necessary.

In the case of the Open Banking ecosystem, the hashing algorithm used is SHA1. For our sample JWK, we can compute the hash using openssl as follows (as before, we use sed to manually transform the base64 encoding to base64url).

echo -n '{"e":"AQAB","kty":"RSA","n":"10iGQ5l5IdqBP1l5wb5BDBZpSyLs4y_Um-kGv_se0BkRkwMZavGD_Nqjq8x3-fKNI45nU7E7COAh8gjn6LCXfug57EQfi0gOgKhOhVcLmKqIEXPmqeagvMndsXWIy6k8WPPwBzSkN5PDLKBXKG_X1BwVvOE9276nrx6lJq3CgNbmiEihovNt_6g5pCxiSarIk2uaG3T3Ve6hUJrM0W35QmqrNM9rL3laPgXtCuz4sJJN3rGnQq_25YbUawW9L1MTVbqKxWiyN5WbXoWUg8to1DhoQnXzDymIMhFa45NTLhxtdH9CDprXWXWBaWzo8mIFes5yI4AJW4ZSg1PPO2UJSQ"}' | openssl sha1 -binary | openssl base64 | sed 's/+/-/g; s/\//_/g; s/=//g'

This gives us the following thumbprint:

EF71iSaosbC5C4tC6Syq1Gm647M

By using this value in the "kid" field of our token header, we can be sure that relying parties can find the corresponding public key in the JWK published by the Open Banking Directory.

Conclusion

Key identifiers are a critical part of the token validation process for OpenID Connect and any other JWS based trust model. They provide the means for correlating JOSE signatures with their corresponding keys, and deserve careful consideration when building an authentication and authorisation ecosystem. Using a standard such as RFC 7638 ensures robust and unique key identifiers in large scale environments, with consistency of KIDs between all participants.

In the next article, we’ll look at how to implement standards based key identifiers in real life, using the ForgeRock Identity Platform.

--

--