Abstract
This library implements a subset of the RFCs 7515 (JSON Web Signature), 7517 (JSON Web Key) and 7518 (JSON Web Algorithms). It uses and works together with Jakarta JSON Processing. It supports the JWS Compact Serialization and it can therefore be used, for instance, to validate digital signatures of JSON Web Tokens issued by an Open ID Connect Provider, e.g. Keycloak.
Build
Maven and a JDK 17+ are required to build the project. First clone the project:
git clone https://github.com/chr78rm/json-web-signature.git
and then you can install the library with
cd json-web-signature mvn clean install
into the local Maven repo.
License and Source Control
The released source code is available in GitHub and is licensed under the GPLv3:
https://github.com/chr78rm/json-web-signature.
Please note that the test suite does use some examples from the RFCs to validate its correctness. For this reason, I don't want left the
Revised BSD License unmentioned. That BSD license covers the use of such code components in IETF documents, see
Trust Legal Provisions for details. The test suite, strictly speaking, is not
an integral part of the distribution. The downloads provided by this site and through Maven Central are coming without the tests.
JSON Web Keys and Algorithms
JSON Web Keys are JSON data structures representing cryptographic keys. The RFC 7517 (JSON Web Key) defines only the common parameters such like the mandatory "kty" (the key type identifying the cryptographic family of the key) or the optional "kid" (the key id which may be used by an application to look up, for instance, the key required to validate a digital signature) and leaves the parameters specific to certain cryptographic algorithms to RFC 7518 (JSON Web Algorithms). There are further RFCs describing the usage of additional algorithms like EdDSA and their respective specific keys not covered by RFC 7518.
This distribution presently supports the algorithms HS256 (HMAC using SHA-256), RS256 (RSASSA-PKCS1-v1_5 using SHA-256), ES256 (ECDSA using P-256 and SHA-256) and ES512 (ECDSA using P-521 and SHA-512) and their respective key types ("oct", "RSA and "EC"). Please note that RS256 is somewhat outdated and its use is considered slightly unsecure (Bleichenbacher's attack). Therefore the application of RS256 is discouraged within financial applications. Use ES256 or ES512 instead.
Key Pairs
EC
The simplest way to create a JSON Web Keypair using this distribution is as following:
JsonWebKeyPair jsonWebKeyPair = JsonWebKeyPair.of() .build();
One essential feature of this library is the representation of the relevant key parameters by an appropriate JSON object model using Jakarta JSON Processing. That is
JsonObject jsonObject = jsonWebKeyPair.toJson();
translates the internally generated key parameter into a JSON Object. Serializing (and pretty printing) gives you the textual representation of the JSON Web Keypair within a file:
JsonWriterFactory jsonWriterFactory = Json.createWriterFactory(Map.of(JsonGenerator.PRETTY_PRINTING, Boolean.TRUE)); Path path = Path.of("json", "examples", "my-first-jsonwebkeypair.json"); try (FileOutputStream fileOutputStream = new FileOutputStream(path.toFile()); JsonWriter jsonWriter = jsonWriterFactory.createWriter(fileOutputStream)) { jsonWriter.write(jsonWebKeyPair.toJson()); }
That keypair looks like this:
{ "kty": "EC", "crv": "P-256", "x": "_hR0SZSiWB98ayiuE1TTWpF38HlYGe9l203mVsOOzSk", "y": "ORoSVJOMPTGVXRgcJ8Zx7gQVb8nRaALVDrMOMEntLK4", "d": "YLo5DRnKFFWc8PEWOCctb_vNwap_4uvueKn6dOv1_go" }
Per default this distribution generates an EC key pair using the curve P-256 specified within NIST SP 800-186 Recommendations for Discrete Logarithm-based Cryptography: Elliptic Curve Domain Parameters. The point (x,y) constitutes the public part of the key whereas the scalar d represents the private part. The base point G of the curve's public domain parameter multiplied with the scalar d yields (x,y). The reverse operation - computing d given only G and (x,y) - is difficult. This is the famous Discrete Logarithm problem, but in additive formulation. All numbers are encoded using Base64-URL-Encoding.
To obtain just the public key part of the key pair you can simply write
JsonWebPublicKey jsonWebPublicKey = jsonWebKeyPair.jsonWebPublicKey();
Again, the appropriate JSON object can be retrieved by invoking jsonWebPublicKey.toJson():
{ "kty": "EC", "crv": "P-256", "x": "_hR0SZSiWB98ayiuE1TTWpF38HlYGe9l203mVsOOzSk", "y": "ORoSVJOMPTGVXRgcJ8Zx7gQVb8nRaALVDrMOMEntLK4" }
The other way round is also possible. All JsonWebKey classes exhibit a static fromJson(JsonObject jwkView) method:
Path path = Path.of("json", "examples", "my-first-jsonwebkeypair.json"); JsonObject jsonObject; try (FileInputStream fileInputStream = new FileInputStream(path.toFile()); JsonReader jsonReader = Json.createReader(fileInputStream)) { jsonObject = jsonReader.readObject(); } JsonWebKeyPair recoveredJsonWebKeyPair = JsonWebKeyPair.fromJson(jsonObject); assert recoveredJsonWebKeyPair.equals(jsonWebKeyPair);
Since the parameters of a JsonWebPublicKey are a subset from the parameters of a JsonWebKeyPair the following code works as well:
JsonWebPublicKey recoveredJsonWebPublicKey = JsonWebPublicKey.fromJson(jsonObject); assert recoveredJsonWebPublicKey.equals(jsonWebKeyPair.jsonWebPublicKey());
You can add a key identification straight away when generating the key pair:
String kid = UUID.randomUUID().toString(); JsonWebKeyPair jsonWebKeyPair = JsonWebKeyPair.of() .withKid(kid) .build();
Now, the textual representation looks like:
{ "kty": "EC", "kid": "3e3909ab-a73d-4116-86c8-3ea01aa0b380", "crv": "P-256", "x": "FOsdxkqUBUaH84csBFJC2fXIzNZ0ZNNL6tNS2eM9Y3Y", "y": "onFu1ZkXfs2VmdpIZ4RpIwRD17N3guewL_jDwD83IMw", "d": "9rNFXW1uqesR_qf0kpe5NfOedO7GIAdr61og0SoQARI" }
Since we have created a new key pair, we have got new values for d and (x,y).
If you are not satisfied with the default algorithm parameter specified for the key generation you can provide appropriate an AlgorithmParameterSpec and optionally SecureRandom instances by yourself:
ECParameterSpec secp521r1 = JsonWebKey.SECP521R1; JsonWebKeyPair jsonWebKeyPair = JsonWebKeyPair.of(secp521r1) .withSecureRandom(SecureRandom.getInstanceStrong()) .build();
Only certain ECGenParameterSpec (or rather ECParameterSpec) and RSAKeyGenParameterSpec instances are presently supported. All three classes are implementing the AlgorithmParameterSpec interface. (The curve secp521r1 is identical to curve P-521). That would look like:
{ "kty": "EC", "crv": "P-521", "x": "AUokWRQYHrxj3-Gu7T_f75pBjeNzmWPLAPNXRk-mJS6kz1RHZau1G7MZvU_R6Tfs0uDxnWrlykxZ-ApVWwhH1GVB", "y": "ADnb-uI7BQan5TVYgIa_u5P1ymLUs0HqWcKGwithciXLXL_kcwwSdx5mHHqd2amEmZKHed1Mv7RRxMcBUuJa5A-C", "d": "AeUiyAto1MbFy8GKV1Mkmx-f_GdcMupxKTal1lJsrFOTgrVs3Wbz0o6RscYefqMpsm61Ojx4K6Yd0d0ZR1SeFF21" }
You might have got a key pair already, for example:
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); ECGenParameterSpec ecGenParameterSpec = new ECGenParameterSpec("secp256r1"); keyPairGenerator.initialize(ecGenParameterSpec); KeyPair keyPair = keyPairGenerator.generateKeyPair();
(secp256r1 is another name for curve P-256). Now you can write:
String kid = UUID.randomUUID().toString(); JsonWebKeyPair jsonWebKeyPair = JsonWebKeyPair.of(keyPair) .withKid(kid) .build();
RSA
A 2048-bit sized RSA key pair can be generated as follows:
AlgorithmParameterSpec algorithmParameterSpec = new RSAKeyGenParameterSpec(2048, RSAKeyGenParameterSpec.F4); JsonWebKeyPair jsonWebKeyPair = JsonWebKeyPair.of(algorithmParameterSpec) .build();
This gives something like that:
{ "kty": "RSA", "n": "rAauSuJjlDArTum2mZZjhUvNceK0cYVb3vnl0dvLNlNoy0hz75vEKPhvPzEQauUMfjiiUV5Ayj6HkGZzQErgE88KKNIt2gotnjIfrgs9E7rzlwIu_Bl-9Fdlqfa5qiwPGF0qLdQyV4ejLfMRYIGFc57YTKbOe-V9amlotDwr-yYtkIF_DmTB3NE20T8QrxQyIw4nCXgq_OYsH_SMG-9Vz9QoSTOjsdZVhYER1C_zDuEEok6mCYvjsS_6OnHIUpCnycO7SObFXuyznQ81sVHPpErocKn9mMDbKm7avj3fXaBoy2EC4I2GKrXf1nuNEusyG6XZnP5ZLOt6EX7jSofFAw", "e": "AQAB", "d": "Sf4Rp9oalNnnNukmBof-RI5nTs4BaTbAXndEl_CfRr23vIBshaqNREYfq6GR-ziMGBqKDs-otJUSwFSgzA2otjx-lTJaUIfCOWI76COjYMIwFkr1JLBewAB7lRCvqXeMAqHIC7BS-v03fgMn_UeYvDOdu9KfzADY4hV22Nds33hFCWzVeAQLH08nYUDr_Q75F0Cbx4SA-rwxZRes0_lNHjn8NSB2BFC8xRZ6Jljxl-TdV3rFQ1u6Pyt6uMhdTa8b64Bca8Tw8KLMNWTGKdNT9AB7tXrccpDLlTXzmGssGx47_hT3aBAU5EqS7Vw-qwaEtsAbV7pij-Yo8wAPgwzEIQ" }
Public Keys
As we have seen, JsonWebPublicKeys can be extracted from a JsonWebKeyPair instance or its textual JSON representation. But a JsonWebPublicKey can also be created on its own. Supposing that someone has generated a key pair like this:
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); ECGenParameterSpec ecGenParameterSpec = new ECGenParameterSpec("secp256r1"); keyPairGenerator.initialize(ecGenParameterSpec); KeyPair keyPair = keyPairGenerator.generateKeyPair();
The accompanying public key part can now be used to instantiate a JsonWebPublicKey:
PublicKey publicKey = keyPair.getPublic(); JsonWebPublicKey jsonWebPublicKey = JsonWebPublicKey.of(publicKey) .build();
HMACs
A HMAC is a Message Authentication Code (MAC) based upon a cryptographic hash function and a secret (symmetric) key. This distribution supports the HS256 algorithm which is a synonym for HMAC using SHA-256. One way to instantiate a JsonWebSecretKey is shown below:
String kid = UUID.randomUUID().toString(); final int KEY_SIZE = 1024; final String ALGORITHM = "HmacSHA256"; KeyGenerator keyGenerator = KeyGenerator.getInstance(ALGORITHM); keyGenerator.init(KEY_SIZE); SecretKey secretKey = keyGenerator.generateKey(); JsonWebSecretKey jsonWebSecretKey = JsonWebSecretKey.of(secretKey) .withKid(kid) .build();
Or you can go with the defaults (keysize=256 bits, algorithm="HmacSHA256"):
String kid = UUID.randomUUID().toString(); JsonWebSecretKey jsonWebSecretKey = JsonWebSecretKey.of() .withKid(kid) .build();
The JSON key representation will look like that:
{ "kty": "oct", "kid": "4926395d-fea3-4d41-bd75-14072a7657ed", "k": "7xSLzHAyoaIqG2IMoIKWjrx7zeRfTM1I7Ilf4RUtC9M", "alg": "HS256" }
JOSE Header
JOSE means JSON Object Signing and Encryption. A JOSE header describes the applied cryptographic operations and parameters. When using the JWS Compact Serialization the JOSE Header is integrity protected by the digital signature, that is it cannot be modified without invalidating the signature as well. Of course you must have an idea which (public) key should be used to validate this signature. An adversary might pretend being someone else and might try to smuggle in her own (public) key. Open ID Connect Provider, like Keycloak, exhibit REST APIs which can be used to retrieve public keys and certificates indicated by key identifications (those kids are usually UUIDs). In the latter scenario the authenticity of a particular Open ID Connect Provider can be established by HTTPS and accompanying certificate chains.
You don't have to consult the JOSEHeader class if you use the Fluent API to create JSON Web Signatures because then the JOSE Header will be inferred from your input. But it might be useful if you must provide a custom JOSE header, e.g. by directly using the JWSSigner.
This distribution presently supports the following JOSE header parameter
The alg and jwk parameters will preferably be inferred from a supplied JsonWebPublicKey:
JsonWebKeyPair jsonWebKeyPair = JsonWebKeyPair.of() .build(); JOSEHeader joseHeader = JOSEHeader.of(jsonWebKeyPair.jsonWebPublicKey()) .build();
The default key type when generating JsonWebKeyPairs is EC (Elliptic Curve) using the curve P-256. RFC 7518 (JSON Web Algorithms) couples the P-256 with the SHA-256 cryptographic hash function and calls it ES256. The textual representation of the JOSEHeader will therefore look like:
{ "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": "mWpT-lYmrYTpwWojzV2RP1Lpcj_YzLCZ2_YLr99HVHw", "y": "IGd23KOnwgqM8TncSBCxHuEGFXEMq0buEiV05raehIo" } }
You can add a kid to the JsonWebKey like this
String kid = UUID.randomUUID().toString(); JsonWebKeyPair jsonWebKeyPair = JsonWebKeyPair.of() .withKid(kid) .build(); JOSEHeader joseHeader = JOSEHeader.of(jsonWebKeyPair.jsonWebPublicKey()) .build();
and it will be copied to the JOSEHeader:
{ "alg": "ES256", "kid": "5b0882cb-ac41-4346-82e9-9c5ebefc7852", "jwk": { "kty": "EC", "kid": "5b0882cb-ac41-4346-82e9-9c5ebefc7852", "crv": "P-256", "x": "hwiIvSiFzrFxCVV4UKKDH-1x6P6LEwurcfL4EhNWhb8", "y": "pLzcIbPsaRAO9_PccHBCPphxHPlPHiMpAcJmdAZe8jY" } }
Alternatively you can indicate the kid only within the JOSEHeader:
String kid = UUID.randomUUID().toString(); JsonWebKeyPair jsonWebKeyPair = JsonWebKeyPair.of() .build(); JOSEHeader joseHeader = JOSEHeader.of(jsonWebKeyPair.jsonWebPublicKey()) .withKid(kid) .build();
That will look like:
{ "alg": "ES256", "kid": "6f0137f3-9ab1-4e10-bc29-9a84fe89c23d", "jwk": { "kty": "EC", "crv": "P-256", "x": "pCzCkBESgNsXd4IY0QCjVNGbr19pE3JIkwi7GD_Ab8s", "y": "PjXmEY1YC-iAeAm4oJmuAC4RZLs8eZeJ_f1JJUyjPyY" } }
When using both methods at once the kids must match or an IllegalArgumentException will be raised.
The typ parameter doesn't have any influence on the signing process. Applications operating on JWS signatures may use it to differentiate between multiple objects with signatures present in an application's data structure. The typ value JOSE indicates a generic JWS using the Compact Serialization whereas the value JWT indicates a JSON Web Token. JWTs certificate certain claims that can be used to determine which operations are allowed for the user presenting a particular JWT.
String kid = UUID.randomUUID().toString(); JsonWebKeyPair jsonWebKeyPair = JsonWebKeyPair.of() .build(); JOSEHeader joseHeader = JOSEHeader.of(jsonWebKeyPair.jsonWebPublicKey()) .withKid(kid) .withTyp("JOSE") .build();
This gives something like that:
{ "alg": "ES256", "typ": "JOSE", "kid": "1b220040-1cd2-480e-b29a-49c453cb6e4f", "jwk": { "kty": "EC", "crv": "P-256", "x": "CZD8Mm42nXpV9Fl92oV4DB32hnTGM_-UnfcFAm5Udbg", "y": "4KQ3kKv2i0B9nH7tc7_Xl98oYqEQYJPbWXe-CfrvkVI" } }
Fluent API
The fluent API is the preferred entry point to create (and verify) JWS signatures since it is considered more stable. The more low level classes like JWSSigner are subject to changes. You may additionally want to look up the state machine diagram that visualizes the different possibilities to create a JSON Web Signature with the fluent API. You can see from the diagram that a (signing) key must be provided (of course!), either an instance of the discussed JsonWebKeys (JsonWebKeyPair or rather a JsonWebSecretKey) or raw key material provided by objects of JDK classes (KeyPair or rather a SecretKey). After you have provided the key, you may skip directly to the provision of the actual payload since neither typ or kid are mandatory. As an alternative a raw custom JOSE header can be supplied before submitting the payload.
Round Trip
Signing
Our first example uses a JsonWebKeyPair whose private part is needed to produce the signature and whose public part is exposed within the JOSE header:
JsonWebKeyPair jsonWebKeyPair = JsonWebKeyPair.of() .withSecureRandom(SecureRandom.getInstanceStrong()) .build();
Next, we need a payload. As an example, an OpenId Connect Provider certifies certain claims about an entity by creating a digitally signed JSON Web Token (JWT). That entity can be a user who wants to access a particular protected resource managed by an application. The application decides, based upon the certified claims, if it may grant access to the requested resource.
String strPayload = """ { "iss": "OpenIDConnect-Provider", "exp": 1744732996, "aud": "Protected App", "jti": "e575fa68-4d24-4398-a2c8-87432d8aa57b", "name": "Tina Tester", "email": "tina-tester@xyz.abc", "roles": [ "app-user", "app-tester" ] } """; JsonObject payload; try (StringReader stringReader = new StringReader(strPayload); JsonReader jsonReader = Json.createReader(stringReader)) { payload = jsonReader.readObject(); }
As shown above the user 'Tina Tester' has the roles 'app-user' and 'app-tester'. The token itself expires on Sunday, 1st June 2025 10:00:00 (UTC). The audience of this token is the mentioned app managing a protected resource. Receiving the digitally signed token the app first validates the signature and then makes its decision depending on the actual claims.
The workflow for creating a JSON Web Signature may look like this:
String kid = UUID.randomUUID().toString(); JWSCompactSerialization compactSerialization = JWS.createSignature() .webkey(jsonWebKeyPair) .typ("JWT") .kid(kid) .payload(payload) .sign();
The textual representation of the JWS Compact Serialization are three Base64-URL encoded parts separated by two dots, e.g.:
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlOGQyOTA3LTk5YjAtNDQ5Zi04MzVjLTY5ZWZhNjc1YjBiNSIsImp3ayI6eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6ImhjTUF3X0JxUXN2NDU3RHh2UnprQVJXRDl4MWVsTm9EX3RTREwtcmVlUTAiLCJ5IjoiVmFjM0M4ejlscXdDNmdxbTl1bVNLb0tfRnE0OGU1MnJyd2xqeF81SFloMCJ9fQ.eyJpc3MiOiJPcGVuSURDb25uZWN0LVByb3ZpZGVyIiwiZXhwIjoxNzQ0NzMyOTk2LCJhdWQiOiJQcm90ZWN0ZWQgQXBwIiwianRpIjoiZTU3NWZhNjgtNGQyNC00Mzk4LWEyYzgtODc0MzJkOGFhNTdiIiwibmFtZSI6IlRpbmEgVGVzdGVyIiwiZW1haWwiOiJ0aW5hLXRlc3RlckB4eXouYWJjIiwicm9sZXMiOlsiYXBwLXVzZXIiLCJhcHAtdGVzdGVyIl19.XvBeAVcUHMvZj1f2xw4WRB52_Ii721u5DpGGqbRwpim4xkXWcppVdhYMb3xyLbJzK9ZoR3mgTJ8ZFx_eNpui9A
The first part is the (protected) JOSE Header, the second part is the actual payload and the last part constitutes the signature. You can recover the decoded JOSE Header simply by invoking compactSerialization.strJoseHeader(). This gives something like that:
{"alg":"ES256","typ":"JWT","kid":"de8d2907-99b0-449f-835c-69efa675b0b5","jwk":{"kty":"EC","crv":"P-256","x":"hcMAw_BqQsv457DxvRzkARWD9x1elNoD_tSDL-reeQ0","y":"Vac3C8z9lqwC6gqm9umSKoK_Fq48e52rrwljx_5HYh0"}}
Everything is squashed on one line! Indeed white spaces matter when generating the signature. That is to say logically identical headers and payloads may lead to different signatures (and compact serializations) depending on line endings (Unix vs. Windows), indentation and things like that. The default procedure is to invoke toString() on the JSON objects constituting header and payload prior to signing. The reference implementation of Jakarta JSON Processing (Eclipse Parsson) depicts the JSON text of JSON structures without any (white) spaces. That may remove any ambiguities but it is unfortunately not specified. As an alternative you may inject your own Json2StringConverter. Such a Json2StringConverter must implement the single method String convert(JsonStructure jsonStructure). This distribution comes with the PrettyStringConverter that uses the PRETTY_PRINTING facility of Jakarta JSON Processing. That is we could also have written:
JWSCompactSerialization compactSerialization = JWS.createSignature() .webkey(jsonWebKeyPair) .typ("JWT") .kid(kid) .payload(payload) .sign(new PrettyStringConverter());
Using the same key this leads to a different JWS Compact Serialization:
ewogICAgImFsZyI6ICJFUzI1NiIsCiAgICAidHlwIjogIkpXVCIsCiAgICAia2lkIjogImRlOGQyOTA3LTk5YjAtNDQ5Zi04MzVjLTY5ZWZhNjc1YjBiNSIsCiAgICAiandrIjogewogICAgICAgICJrdHkiOiAiRUMiLAogICAgICAgICJjcnYiOiAiUC0yNTYiLAogICAgICAgICJ4IjogImhjTUF3X0JxUXN2NDU3RHh2UnprQVJXRDl4MWVsTm9EX3RTREwtcmVlUTAiLAogICAgICAgICJ5IjogIlZhYzNDOHo5bHF3QzZncW05dW1TS29LX0ZxNDhlNTJycndsanhfNUhZaDAiCiAgICB9Cn0.ewogICAgImlzcyI6ICJPcGVuSURDb25uZWN0LVByb3ZpZGVyIiwKICAgICJleHAiOiAxNzQ0NzMyOTk2LAogICAgImF1ZCI6ICJQcm90ZWN0ZWQgQXBwIiwKICAgICJqdGkiOiAiZTU3NWZhNjgtNGQyNC00Mzk4LWEyYzgtODc0MzJkOGFhNTdiIiwKICAgICJuYW1lIjogIlRpbmEgVGVzdGVyIiwKICAgICJlbWFpbCI6ICJ0aW5hLXRlc3RlckB4eXouYWJjIiwKICAgICJyb2xlcyI6IFsKICAgICAgICAiYXBwLXVzZXIiLAogICAgICAgICJhcHAtdGVzdGVyIgogICAgXQp9.Z0sxGflB3Oh-sBsN4wz1v6qu5GtVLo3GluUpNYitAlLnye8Jp8jXjgl4T5cx-cdomFEXkHuRQLbE_4piEVPbuQ
Invoking compactSerialization.strJoseHeader() gives now a more human-friendly text representation of the inferred JOSE header:
{ "alg": "ES256", "typ": "JWT", "kid": "de8d2907-99b0-449f-835c-69efa675b0b5", "jwk": { "kty": "EC", "crv": "P-256", "x": "hcMAw_BqQsv457DxvRzkARWD9x1elNoD_tSDL-reeQ0", "y": "Vac3C8z9lqwC6gqm9umSKoK_Fq48e52rrwljx_5HYh0" } }
The same applies when invoking compactSerialization.strPayload(). The optionally provided Json2StringConverter works on both header and payload.
As an alternative you can pass both (custom) JOSE header and payload as String objects provided that both are valid text representations of JSON structures (objects or arrays). RFC 7515 allows arbitrary payloads, that is payloads that aren't necessarily JSON structures, but this isn't presently supported by this distribution. The snippet below directly passes the payload as string object:
JWSCompactSerialization compactSerialization = JWS.createSignature() .webkey(jsonWebKeyPair) .typ("JWT") .kid(kid) .payload(strPayload) .sign();
JOSE headers and payloads passed as strings won't be processed by Json2StringConverters.
Verifying
The application needs, aside from the compact serialization, the correct public key for the verification of the signature. Correctness means not just any public key but in our case the public counterpart of the key used by the OpenID Connect Provider for signing the JWT. One way to achieve this would be to configure the Relying Party (OpenID Connect jargon for our App managing a protected resource) with the public key beforehand. Another possibility could be to use the provided key identification (kid) to look up the key from the OpenID Connect Provider in real time. More about this in the next section. Keep in mind that an attack vector for an adversary would be to smuggle in her own public key.
boolean validated = JWS.createValidator() .compactSerialization(compactSerialization) .webkey(jsonWebKeyPair.jsonWebPublicKey()) .validate(); assert validated;
Validation of a JWT issued by Keycloak
Keycloak's default algorithm for signing tokens is RS256 but you can change this easily either globally or per client application. I have used custom X.509 direct grants to request some access tokens as illustration. Client applications participating in a Single-Sign-On workflow, e.g. the Authorization Code Flow, are receiving such tokens in exchange for an authorization code on the backchannel between client application and Keycloak.
ES256
An access token signed with the ES256 algorithm is shown below:
eyJhbGciOiJFUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJSZjFjMHhyRTAzVWQ2OGthd1BOX1pHY1o5R1VObTFBdTFnSTBpZXF4QzQ0In0.eyJleHAiOjE3NDU0MTUyOTIsImlhdCI6MTc0NTQxNDk5MiwianRpIjoiNDllN2Q3MGMtZGYzYS00ZTRmLTlmNTQtMWFlNWEzZDk3OGQ3IiwiaXNzIjoiaHR0cHM6Ly9uZXh0LWtleWNsb2FrOjg0NDMvcmVhbG1zL3Rlc3QiLCJhdWQiOlsiZGF0ZXRpbWUtc2VydmljZSIsImFjY291bnQiXSwic3ViIjoiMGEwMmUxMWMtNzY1My00ZmE5LWFlYTctYzI2MDRkODViOTk1IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiZGF0ZXRpbWUtc2VydmljZSIsInNpZCI6ImEwMzI1OTgwLTRhZjAtNDA4Mi1iYWQ1LTM0MmIxMGE1NzUxNCIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cDovL3VidW50dS12bSJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy10ZXN0Iiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiIsImFwcC10ZXN0ZXIiLCJhcHAtdXNlciJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoiZW1haWwgcHJvZmlsZSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6IlRpbmEgVGVzdGVyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoidGVzdGVyLTAiLCJnaXZlbl9uYW1lIjoiVGluYSIsImZhbWlseV9uYW1lIjoiVGVzdGVyIiwiZW1haWwiOiJ0aW5hLXRlc3RlckB4eXouYWJjIn0.eo4TZbETUntis2LcHBmsS-4SfRqExZqA8fTcvZkdDCcuCdHbS97U5retTnFlnm-LhNgF6-uE5Q8toMtsrzkSMQ
As explained above, the compact serialization consists of three Base64 URL encoded parts (header, payload and signature) separated by dots. This is the decoded header:
{ "alg": "ES256", "typ": "JWT", "kid": "Rf1c0xrE03Ud68kawPN_ZGcZ9GUNm1Au1gI0ieqxC44" }
And again everything would be an one-liner without white spaces. I have pretty printed the above and subsequent examples for clarity. Keycloak provides JSON documents containing some metadata via /.well-known/openid-configuration endpoints. Such endpoints are maintained for every configured realm, thus https://<KEYCLOAK_HOST>:<PORT>/realms/<REALM>/.well-known/openid-configuration. Consulting such an endpoint gives you, among other things, an URL for retrieving the public keys. As of Keycloak 26 that would be https://<KEYCLOAK_HOST>:<PORT>/realms/<REALM>/protocol/openid-connect/certs. Querying this endpoint generates a JSON Web Key Set containing the public keys:
{ "keys": [ ..., { "kid": "Rf1c0xrE03Ud68kawPN_ZGcZ9GUNm1Au1gI0ieqxC44", "kty": "EC", "alg": "ES256", "use": "sig", "x5c": [ "MIIBCjCBsQIGAZZeJB20MAoGCCqGSM49BAMCMA8xDTALBgNVBAMMBHRlc3QwHhcNMjUwNDIyMTUzNDA3WhcNMzUwNDIyMTUzNTQ3WjAPMQ0wCwYDVQQDDAR0ZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAERVIMLIqI9KwvB1vxAlCdqGlot3IZJqR8F3f83zSWZahQAONXk269y2rQOKVHD9PJ6gHI0vWHospNHQGLfLdVuTAKBggqhkjOPQQDAgNIADBFAiEAysMhHPwt5ZUAE9/j2yWP2IpyLNPbfxRYKtK1PpkmpysCIBNJovLXZTjeSNMzOmhNMwjoCHcwCIJgAqZKwDyqQjBE" ], "x5t": "7ULGpBPoi5BLkWU_x1Ktl94x1XI", "x5t#S256": "oxxEkbyWz_wCuvxHirk5kfystuyKpNZ1T6yid6PvdQ0", "crv": "P-256", "x": "RVIMLIqI9KwvB1vxAlCdqGlot3IZJqR8F3f83zSWZag", "y": "UADjV5Nuvctq0DilRw_TyeoByNL1h6LKTR0Bi3y3Vbk" }, ... ] }
Selecting for the given kid enables you to create the corresponding JsonWebPublicKey:
String kid = "Rf1c0xrE03Ud68kawPN_ZGcZ9GUNm1Au1gI0ieqxC44"; Optional<JsonWebPublicKey> optionalJsonWebPublicKey = keys.getJsonArray("keys").stream() .map(value -> value.asJsonObject()) .filter(key -> Objects.equals(key.getString("kid"), kid)) .findFirst() .map(key -> { try { return JsonWebPublicKey.fromJson(key); } catch (GeneralSecurityException ex) { throw new RuntimeException(ex); } });
Now consider the decoded payload:
{ "exp": 1745415292, "iat": 1745414992, "jti": "49e7d70c-df3a-4e4f-9f54-1ae5a3d978d7", "iss": "https://next-keycloak:8443/realms/test", "aud": [ "datetime-service", "account" ], "sub": "0a02e11c-7653-4fa9-aea7-c2604d85b995", "typ": "Bearer", "azp": "datetime-service", "sid": "a0325980-4af0-4082-bad5-342b10a57514", "acr": "1", "allowed-origins": [ "http://ubuntu-vm" ], "realm_access": { "roles": [ "default-roles-test", "offline_access", "uma_authorization", "app-tester", "app-user" ] }, "resource_access": { "account": { "roles": [ "manage-account", "manage-account-links", "view-profile" ] } }, "scope": "email profile", "email_verified": false, "name": "Tina Tester", "preferred_username": "tester-0", "given_name": "Tina", "family_name": "Tester", "email": "tina-tester@xyz.abc" };
For a short (elapsed) time interval an application could have used the above 'claims' - presented within a HTTP header - to make a decision if the corresponding web request can be completed or not. For this the authenticity of the token would have to be validated beforehand:
assert optionalJsonWebPublicKey.isPresent(); assert JWS.createValidator() .compactSerialization(compactSerialization) .webkey(optionalJsonWebPublicKey.get()) .validate();
HS256
HMACs are symmetric algorithms, hence the keys must be kept secret and cannot be transmitted via a publicly available interface like the public keys. Since the secret keys are required for signing and(!) validation, they must be extracted from Keycloak beforehand. As of Keycloak 26 one way of doing this is to export the realm data into a JSON file and the searching for the kid. Please note that this is not officially documented and might change without notice. Assuming the kid equals cae8efe1-61c4-4210-8160-41b18fb50d77 an extract from the exported realm data may look like this:
// This is extracted key material from a Keycloak realm JSON export String strKeyMaterial = """ { "id" : "db786a2e-67d9-4a83-b67c-53f83f50218c", "name" : "hmac-generated", "providerId" : "hmac-generated", "subComponents" : { }, "config" : { "kid" : [ "cae8efe1-61c4-4210-8160-41b18fb50d77" ], "active" : [ "true" ], "secretSize" : [ "256" ], "secret" : [ "SsOhd4No_X8So710mA1iSVQhgTZsq04aRm4FmDPjyvpJRsYwS7SlA9hZjyKdcJWthdEtpQ3Ur1Anz7O8mUIWW-jI9qE-FT5z-HINqh2C4WJXh8uNDGLn8gJlMUXIXBahva5YcQtnzJQ-dPSo1FRYMxSzVWTnug4KOX06I1Ir4FQxf9citnBV3HK0M4EY5kPtXI5JtkSW6yBBnBsWyIjPHldpw6aF-u8KkgWuf-we2t7N-k3l6xgSnMGFTQvHTDDh9O_CfzHXjDv_FOZnoKfmXvMwq4_J8at6elynROesL8YR5ydhV3ClUwXkLS7xP_hFN_rh9JCoyvICP8h0Q4hJ3A" ], "priority" : [ "0" ], "enabled" : [ "true" ], "algorithm" : [ "HS256" ] } }""";
The JSON string referenced by secret contains the Base64-URL encoded key bytes:
JsonObject keyMaterial; try (StringReader stringReader = new StringReader(strKeyMaterial); JsonReader jsonReader = Json.createReader(stringReader)) { keyMaterial = jsonReader.readObject(); } String secret = keyMaterial.getJsonObject("config") .getJsonArray("secret") .getString(0); byte[] secretBytes = JWSBase.decodeToBytes(secret); SecretKeySpec secretKey = new SecretKeySpec(secretBytes, "HmacSHA256"); JsonWebSecretKey jsonWebSecretKey = JsonWebSecretKey.of(secretKey) .build();
Now we need an access token that we have requested from Keycloak:
String strOidcToken = """ { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJjYWU4ZWZlMS02MWM0LTQyMTAtODE2MC00MWIxOGZiNTBkNzcifQ.eyJleHAiOjE3NDU1MDE1NzAsImlhdCI6MTc0NTUwMTI3MCwianRpIjoiMGFhN2RjMWMtNTgwYy00NWRiLTlmOTYtMzA4M2Q1NmQzNzUxIiwiaXNzIjoiaHR0cHM6Ly9uZXh0LWtleWNsb2FrOjg0NDMvcmVhbG1zL3Rlc3QiLCJhdWQiOlsiaHMtMjU2LXRlc3QiLCJhY2NvdW50Il0sInN1YiI6IjBhMDJlMTFjLTc2NTMtNGZhOS1hZWE3LWMyNjA0ZDg1Yjk5NSIsInR5cCI6IkJlYXJlciIsImF6cCI6ImhzLTI1Ni10ZXN0Iiwic2lkIjoiOTlhNDg1YTAtMDI3MC00MGUwLThmMmQtMjdmNWQzZDExOTJkIiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyIvKiJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy10ZXN0Iiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiIsImFwcC10ZXN0ZXIiLCJhcHAtdXNlciJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoiZW1haWwgcHJvZmlsZSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6IlRpbmEgVGVzdGVyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoidGVzdGVyLTAiLCJnaXZlbl9uYW1lIjoiVGluYSIsImZhbWlseV9uYW1lIjoiVGVzdGVyIiwiZW1haWwiOiJ0aW5hLXRlc3RlckB4eXouYWJjIn0.W5TM0JinK6igWO-mBCzHqPEdIFLVoSG-skXri29sWGo", "expires_in": 300, "refresh_expires_in": 1800, "refresh_token": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI5YTY3ZmRkZS03MzFmLTRhODUtYThmOS05NWMwMjAwNTg4YjIifQ.eyJleHAiOjE3NDU1MDMwNzAsImlhdCI6MTc0NTUwMTI3MCwianRpIjoiZGU3ZmQ1ZTMtNTk3NC00YTlhLTk4MDUtNjZiNGYzNjRmMTFiIiwiaXNzIjoiaHR0cHM6Ly9uZXh0LWtleWNsb2FrOjg0NDMvcmVhbG1zL3Rlc3QiLCJhdWQiOiJodHRwczovL25leHQta2V5Y2xvYWs6ODQ0My9yZWFsbXMvdGVzdCIsInN1YiI6IjBhMDJlMTFjLTc2NTMtNGZhOS1hZWE3LWMyNjA0ZDg1Yjk5NSIsInR5cCI6IlJlZnJlc2giLCJhenAiOiJocy0yNTYtdGVzdCIsInNpZCI6Ijk5YTQ4NWEwLTAyNzAtNDBlMC04ZjJkLTI3ZjVkM2QxMTkyZCIsInNjb3BlIjoid2ViLW9yaWdpbnMgcm9sZXMgYmFzaWMgZW1haWwgYWNyIHByb2ZpbGUifQ.DTQES5aPjcRguoZEML7247FvscyALYunDJif7sG6HId7vAo6flHyUJ4goC9LP9AjF3PTCu4NW0lRfTOQfric2g", "token_type": "Bearer", "not-before-policy": 0, "session_state": "99a485a0-0270-40e0-8f2d-27f5d3d1192d", "scope": "email profile" }"""; JsonObject oidcToken; try (StringReader stringReader = new StringReader(strOidcToken); JsonReader jsonReader = Json.createReader(stringReader)) { oidcToken = jsonReader.readObject(); } String accessToken = oidcToken.getString("access_token");
Finally, we can validate the token:
JWSCompactSerialization compactSerialization = JWSCompactSerialization.of(accessToken); boolean validated = JWS.createValidator() .compactSerialization(compactSerialization) .webkey(jsonWebSecretKey) .validate(); assert validated;
Low Level API
These two classes (JWSSigner and JWSValidator) and their underlying infrastructure are certainly subject to change. E.g. coming versions will allow arbitrary payloads that aren't necessarily valid JSON structures. This is currently not supported. The subsequent example has been taken from RFC7515, see appendix A.3, followed to the letter.
JWSSigner
The alg header parameter is the only one that is mandatory within the JOSE header and indeed this parameter will be used by the JWSSigner to deduce the signature algorithm:
String strHeader = """ {"alg":"ES256"}""";
Next, we use the literal payload as given by Appendix A.3:
String strPayload = """ {"iss":"joe",\r "exp":1300819380,\r "http://example.com/is_root":true}""";
Finally, we need a signing key. Appendix A.3 demonstrates JWS using ECDSA with curve P-256 and SHA-256, so here we go:
String strKeyPair = """ {"kty":"EC", "crv":"P-256", "x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU", "y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0", "d":"jpsQnnGQmL-YBIffH1136cspYG6-0iY7X1fCE9-E9LI" }"""; JsonObject webKey; try (StringReader stringReader = new StringReader(strKeyPair); JsonReader jsonReader = Json.createReader(stringReader)) { webKey = jsonReader.readObject(); } JsonWebKeyPair jsonWebKeyPair = JsonWebKeyPair.fromJson(webKey);
Now we have everything together to create the signature:
JWSSigner jwsSigner = new JWSSigner(strHeader, strPayload); JWSCompactSerialization compactSerialization = jwsSigner.sign(jsonWebKeyPair.getKeyPair().getPrivate());
The compact serialization looks like:
eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.oj7YrUP7wptjpLzu8KlOfdgoZSpl3PTJRrSShXdmy3RrpbYhXKwicNn-f3oO_D_z0PUvxUxlM4x-f1MdBohGUQ
The first two parts (Base64-URL encoded header and payload) of the compact serialization shown above are exactly the same as depicted in Appendix A.3 but since the signing process is non-deterministic we have got a different, but nevertheless valid, signature:
assert Objects.equals(compactSerialization.encodedHeader(), "eyJhbGciOiJFUzI1NiJ9"); assert Objects.equals(compactSerialization.encodedPayload(), "eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ"); JWSValidator jwsValidator = new JWSValidator(compactSerialization); assert jwsValidator.validate(jsonWebKeyPair.jsonWebPublicKey().getPublicKey());
JWSValidator
To verify the exact signature given in Appendix A.3, which is equally valid, we must first make some preparations. Below, I am showing the signature octets from A.3:
byte[] r = {14, (byte) 209, 33, 83, 121, 99, 108, 72, 60, 47, 127, 21, 88, 7, (byte) 212, 2, (byte) 163, (byte) 178, 40, 3, 58, (byte) 249, 124, 126, 23, (byte) 129, (byte) 154, (byte) 195, 22, (byte) 158, (byte) 166, 101}; byte[] s = {(byte) 197, 10, 7, (byte) 211, (byte) 140, 60, 112, (byte) 229, (byte) 216, (byte) 241, 45, (byte) 175, 8, 74, 84, (byte) 128, (byte) 166, 101, (byte) 144, (byte) 197, (byte) 242, (byte) 147, 80, (byte) 154, (byte) 143, 63, 127, (byte) 138, (byte) 131, (byte) 163, 84, (byte) 213};
Both r and s can be appended and then Base64-URL encoded to obtain the actual signature:
byte[] signature = new byte[64]; System.arraycopy(r, 0, signature, 0, r.length); System.arraycopy(s, 0, signature, 32, s.length); String encodedSignature = JWSBase.encode(signature); assert Objects.equals(encodedSignature, "DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQxfKTUJqPP3-Kg6NU1Q");
Next, we combine JOSE header, payload and prepared signature to manufacture a compact serialization which can again be validated:
JWSCompactSerialization compactSerializationByAppendixA3 = JWSCompactSerialization.of( "%s.%s.%s".formatted(compactSerialization.encodedHeader(), compactSerialization.encodedPayload(), encodedSignature) ); jwsValidator = new JWSValidator(compactSerializationByAppendixA3); assert jwsValidator.validate(jsonWebKeyPair.jsonWebPublicKey().getPublicKey());
If we arbitrarily flip even only one bit of the signature provided by A.3, e.g. we change the sixth octet (99 == 011000112) into 67 == 010000112, we get very likely an invalid signature:
signature[5] = 67; String fakedSignature = JWSBase.encode(signature); JWSCompactSerialization fakedcompactSerialization = JWSCompactSerialization.of( "%s.%s.%s".formatted(compactSerialization.encodedHeader(), compactSerialization.encodedPayload(), fakedSignature) ); jwsValidator = new JWSValidator(fakedcompactSerialization); assert !jwsValidator.validate(jsonWebKeyPair.jsonWebPublicKey().getPublicKey());
Similarily, if we tamper with the payload - leaving original header and signature untouched - we get another faked compact serialization that fails to validate:
String fakePayload = """ {"iss":"donald",\r "exp":1300819380,\r "http://example.com/is_root":true}"""; JWSCompactSerialization anotherFakedcompactSerialization = JWSCompactSerialization.of( "%s.%s.%s".formatted(compactSerialization.encodedHeader(), JWSBase.encode(fakePayload), encodedSignature) ); jwsValidator = new JWSValidator(anotherFakedcompactSerialization); assert !jwsValidator.validate(jsonWebKeyPair.jsonWebPublicKey().getPublicKey());
UML Diagrams
State Machine Fluent API
JsonWebKey Class Diagram
JWA Class Diagram
JWS Class Diagram
Download
Maven Coordinates
This library has been deployed to Maven Central. The latest version can be referenced by:
<dependency> <groupId>de.christofreichardt</groupId> <artifactId>json-web-signature</artifactId> <version>1.0.0-rc1</version> </dependency>
You will additionally need an implementation for the Jakarta JSON Processing API at runtime, e.g. Eclipse Parsson:
<dependency> <groupId>org.eclipse.parsson</groupId> <artifactId>jakarta.json</artifactId> <version>1.1.7</version> <scope>runtime</scope> </dependency>
An alternative implementation for the Jakarta JSON Processing API would be Joy
<dependency> <groupId>org.leadpony.joy</groupId> <artifactId>joy-classic</artifactId> <version>2.1.0</version> <scope>runtime</scope> </dependency>
Direct Download
Artifact | JAR | Build Time | SHA-256 Checksum |
---|---|---|---|
json-web-signature-1.0.0-rc1 | json-web-signature-1.0.0-rc1.jar | 2025-06-02 15:39 | baa19b91ee24e238dc9f61d66b027cdec2f2039f351fdbf8ff0339de623f57f3 |