Skip to content
JackSparrow414
Go back

Getting Started with JWT and nimbus-jose-jwt

Table of contents

Open Table of contents

Using JWT (JSON Web Token)

Introduction

This article expands on Using JWT with Shiro, providing complete examples of JWT usage in detail.

If your understanding of RSA differs from mine while reading, please first read the RSA explanation later in the article. If you still disagree, comments and discussion are welcome.

JWT’s Purpose and Basic Format

This introduction is enough to get started. It explains what JWT is, what it does, its format and operation, and why to use it.

Basic Format

The documentation above explains the format. Briefly, JWT has three sections:

xxxxx.yyyyy.zzzzz

The first is the Header, which contains the signing algorithm and token type.

{
  "alg": "HS256",
  "typ": "JWT"
}

The Payload mainly contains token information such as issue and expiry times, plus custom information. A more complete payload example is:

{
  "sub": "1000",
  "aud": ["https://app-one.com", "https://app-two.com"],
  "nbf": 1638357246,
  "iss": "http://localhost:18080",
  "exp": 1638357846,
  "iat": 1638357246,
  "jti": "17fcfe5e-f705-481d-bf55-23368988f8d6",
  "scope": "read write"
}

Registered JWT claims and their meanings in the RFC documentation

For complete explanations, see the specification.

The Signature signs the JWT. Specifically, the Base64-encoded Header and Payload are processed with the chosen cryptographic algorithm. Explanation of JWT signatures generated from the encoded Header, Payload, and key

The final output is a Base64-encoded string separated by three dots (·). Decoding it reveals the payload, so sensitive information should not be placed there.

Why Is a Signature Needed?

The second red-highlighted section in the screenshot already explains this: a signature prevents JWT tampering. If the RSA private key is used to encrypt, the correct result can only be obtained by decrypting with the RSA public key on the server. This proves not only that the JWT signature was not altered, but also that this server issued it—another important point. HMAC is similar: the secret also remains server-side, and a matching result shows the JWT came from our server.

JWT Libraries

Common Java JWT libraries include nimbus-jose-jwt and io.jsonwebtoken. The JWT library list includes libraries for other languages too.

Using JWT with Shiro used io.jsonwebtoken and only showed basic code, without discussing library selection in detail.

This article uses nimbus-jose-jwt.

Choosing between nimbus-jose-jwt and jsonwebtoken

Stack Overflow already discusses this. The following two links can help you choose for your circumstances:

Briefly, nimbus-jose-jwt supports more features.

Using nimbus-jose-jwt

The official examples provide a quick introduction. Nimbus divides JWT usage into JWS and JWE. This article mainly explains JWS and introduces JWE as a starting point for readers to explore.

JWS

JSON Web Signature

JWS signs a JWT. As explained above, adding a signature:

Note: signatures are not limited to JWT. Digital signatures are also used in SSL; explore further if interested.

Signing algorithms include HMAC, RSA, and EC. For their use cases, see the algorithm-selection guide, which explains cryptographic goals and the algorithms appropriate to them.

HMAC
When to Use HMAC

HMAC use cases including email verification codes and session identifiers

An example is an email verification code. The second red-highlighted section explains the best use case: data is sent outside the application and must later be recognized by it. The core idea is to ensure the data has not been altered and was generated by us.

A simple Spring Boot application demonstrates this.

Generating a JWT with HMAC
@Configuration
@Component
public class SignerAndVerifierConfiguration {

    private static final String sharedSecret = "31611159e7e6ff7843ea4627745e89225fc866621cfcfdbd40871af4413747cc";
    @Bean(name = "HmacSigner")
    @SneakyThrows
    public JWSSigner generateHmacJwsSigner() {
        SecureRandom random = new SecureRandom();
        random.nextBytes(sharedSecret.getBytes());
        return new MACSigner(sharedSecret);
    }

    @Bean(name = "HmacVerifier")
    @SneakyThrows
    public JWSVerifier getHmacJwsVerifier() {
        SecureRandom random = new SecureRandom();
        random.nextBytes(sharedSecret.getBytes());
        return new MACVerifier(sharedSecret);
    }
}

First configure the HMAC signer, JWSSigner, and verifier, JWSVerifier. Here we use a random string as the HMAC secret.

Then construct the JWT, beginning with the payload. The main API is JWTClaimsSet.Builder().

@Component
public class JWTClaimsSetFactory {

    public JWTClaimsSet buildJWTClaimsSet(String userId) {
        Calendar signTime = Calendar.getInstance();
        Date signTimeTime = signTime.getTime();
        signTime.add(Calendar.MINUTE, 10);
        Date expireTime = signTime.getTime();
        return new JWTClaimsSet.Builder()
                .issuer("http://localhost:18080")
                .subject(userId)
                .audience(Arrays.asList("https://app-one.com", "https://app-two.com"))
                .expirationTime(expireTime)
                .notBeforeTime(signTimeTime)
                .issueTime(signTimeTime)
                .jwtID(UUID.randomUUID().toString())
                .claim("scope", "read write")
                .build();
    }
}

After obtaining the payload, add the Header and sign it with the signer. The main class here is SignedJWT, representing the JWS we use.

@RestController
@RequestMapping("generate")
@Log
public class GenerateTokenController {

    @Autowired
    @Qualifier("HmacSigner")
    private JWSSigner hmacSigner;

    @Autowired
    private JWTClaimsSetFactory jwtClaimsSetFactory;

    @GetMapping("hmac")
    @SneakyThrows
    public String generateHMACToken() {
      // Pass in the header and payload.
        SignedJWT signedJWT = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.HS256).type(JOSEObjectType.JWT).build(), jwtClaimsSetFactory.buildJWTClaimsSet("ADMIN"));
        // Sign it.
        signedJWT.sign(hmacSigner);
        String result = signedJWT.serialize();
        log.info("HMAC token is: \n" + result);
        return result;
    }
}
Parsing a JWT with HMAC
@RestController
@RequestMapping("verify")
public class VerifyTokenController {

    @Autowired
    @Qualifier("HmacVerifier")
    private JWSVerifier hmacVerifier;

    @GetMapping("hmac")
    @SneakyThrows
    public boolean verifyHMACToken(@RequestHeader("Authorization") String token) {
        SignedJWT parse = SignedJWT.parse(token);
        if (!parse.verify(hmacVerifier)) {
            throw new RuntimeException("invalid token");
        }
        verifyClaimsSet(parse.getJWTClaimsSet());
        return true;
    }

    /**
     * Perform all verification here.
     * @param jwtClaimsSet
     */
    private void verifyClaimsSet(final JWTClaimsSet jwtClaimsSet) {
        boolean result = false;
        if (Calendar.getInstance().getTime().before(jwtClaimsSet.getExpirationTime())) {
            result = true;
        }
        if (!result) {
            throw new RuntimeException("token expired");
        }
    }
}
RSA
When to Use RSA

RSA signature use cases and explanation of private-key signing and public-key verification

For example, an OAuth 2.0 server can use it when issuing access tokens. Generate a public/private key pair, sign with the private key, and verify with the public key.

Generating RSA Public and Private Keys Online

For demonstration, use an online RSA generator. In actual use, keys can be generated on the server with OpenSSL.

Put the generated public and private keys into publish-key.pem and private-key.pem respectively, creating the files if needed.

Generating a JWT with RSA

As with HMAC, configure a signer and verifier.

@Configuration
@Component
public class SignerAndVerifierConfiguration {

    @Bean(name = "RsaSigner")
    @SneakyThrows
    public JWSSigner generateRsaJwsSigner(){
        // Read the private key.
        String pemEncodedRSAPrivateKey = PEMKeyUtils.readKeyAsString("rsa/private-key.pem");
        RSAKey rsaKey = (RSAKey) JWK.parseFromPEMEncodedObjects(pemEncodedRSAPrivateKey);
        return new RSASSASigner(rsaKey.toRSAPrivateKey());
    }

    @Bean(name = "RsaVerifier")
    @SneakyThrows
    public JWSVerifier getRsaJWSVerifier() {
      // Read the public key.
        String pemEncodedRSAPublicKey = PEMKeyUtils.readKeyAsString("rsa/publish-key.pem");
        RSAKey rsaPublicKey = (RSAKey) JWK.parseFromPEMEncodedObjects(pemEncodedRSAPublicKey);
        return new RSASSAVerifier(rsaPublicKey);
    }
}

Place the two files under the project’s resources and read them when initializing the signer and verifier.

@RestController
@RequestMapping("generate")
@Log
public class GenerateTokenController {

    @Autowired
    @Qualifier("RsaSigner")
    private JWSSigner rsaSigner;

    @Autowired
    private JWTClaimsSetFactory jwtClaimsSetFactory;

    @GetMapping("{userId}")
    @SneakyThrows
    public String generateRSAToken(@PathVariable String userId) {
      // Choose RS256 for the header.
        SignedJWT signedJWT = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).type(JOSEObjectType.JWT).build(), jwtClaimsSetFactory.buildJWTClaimsSet(userId));
      // Sign it.
        signedJWT.sign(rsaSigner);
        String result = signedJWT.serialize();
        log.info("token is: \n" + result);
        return result;
    }
}

The payload construction is the same as in the example, so I will not repeat that code.

Parsing a JWT with RSA
@RestController
@RequestMapping("verify")
public class VerifyTokenController {

    @Autowired
    @Qualifier("RsaVerifier")
    private JWSVerifier rsaVerifier;

    @GetMapping
    @SneakyThrows
    public boolean verifyRSAToken(@RequestHeader("Authorization") String token) {
        SignedJWT parse = SignedJWT.parse(token);
        if (!parse.verify(rsaVerifier)) {
            throw new RuntimeException("invalid token");
        }
        verifyClaimsSet(parse.getJWTClaimsSet());
        return true;
    }
}

JWE

JWS uses a signature to prevent tampering, but decoding Base64 still reveals the payload. In practice, our JWT may only contain userId. With OAuth 2.0, it may also contain scope and similar information, but exposing these through decoding has little impact on us.

If you want to encrypt the payload so decoding does not reveal it, use JWE: JSON Web Encryption.

A reminder:

Encrypting Content with RSA
@Component
@Configuration
public class EncryptAndDecryptConfiguration {

    @Bean
    @SneakyThrows
    public JWEEncrypter generateRsaJweEncrypter() {
        String pemEncodedRSAPublicKey = PEMKeyUtils.readKeyAsString("rsa/publish-key.pem");
        RSAKey rsaPublicKey = (RSAKey) JWK.parseFromPEMEncodedObjects(pemEncodedRSAPublicKey);
        return new RSAEncrypter(rsaPublicKey);
    }

    @Bean
    @SneakyThrows
    public JWEDecrypter getRsaJweDecrypter() {
        String pemEncodedRSAPrivateKey = PEMKeyUtils.readKeyAsString("rsa/private-key.pem");
        RSAKey rsaKey = (RSAKey) JWK.parseFromPEMEncodedObjects(pemEncodedRSAPrivateKey);
        return new RSADecrypter(rsaKey);
    }
}

Configure JWEEncrypter and JWEDecrypter.

@RestController
@RequestMapping("secret")
@Log
public class SecretController {

    @Autowired
    private JWEEncrypter jweEncrypter;

    @Autowired
    private JWTClaimsSetFactory jwtClaimsSetFactory;

    @GetMapping("{userId}")
    @SneakyThrows
    public String secretToken(@PathVariable final String userId) {
      // Set the JWE header.
        JWEHeader header = new JWEHeader(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A128GCM);
        EncryptedJWT encryptedJWT = new EncryptedJWT(header, jwtClaimsSetFactory.buildJWTClaimsSet(userId));
      // Encrypt with publishKey.
        encryptedJWT.encrypt(jweEncrypter);
        String result = encryptedJWT.serialize();
        log.info("encrypt token is: \n" + result);
        return result;
    }
}

Decode the generated JWT’s Base64 string again: the payload contents are no longer visible. Encoded and decoded views of an encrypted JWT with an unreadable Payload

Decrypt it:

    @GetMapping("decrypt")
    @SneakyThrows
    public void decryptRSASecretToken(@RequestHeader("Authorization") String token) {
        EncryptedJWT encryptedJWT = EncryptedJWT.parse(token);
       // Decrypt with the private key.
        encryptedJWT.decrypt(jweDecrypter);
    }

JWS + JWE

You might now wonder whether signing and encryption can be combined. Of course they can. See Nimbus’s signed and encrypted JWT example. I will not expand on it here.

The documentation recommends signing first and then encrypting. The first line of the example explains why.

Some Explanations of RSA

Readers familiar with RSA may be puzzled by how I used it for JWS above. Questions might include:

  1. RSA asymmetric encryption uses public/private key pairs. Why does only the server have both keys here?

    Answer: the token-issuing server communicates with the browser. The browser does not need to decrypt the token; it only includes it in the request header next time. It therefore does not need to maintain its own RSA key pair here.

  2. Why encrypt with the RSA private key and decrypt with the public key? Isn’t it normally public-key encryption and private-key decryption?

    Answer: public-key encryption and private-key decryption apply to content encryption. Our JWS scenario signs content. In practice, use RSA according to your requirement. Briefly:

    First usage: public-key encryption and private-key decryption—for encryption/decryption. Second usage: private-key signing and public-key verification—for signatures.

    If this seems confusing, do not memorize it mechanically. Think this way: Simply ask yourself: With encryption, I do not want others to know my message, so only I should be able to decrypt it. The public key encrypts, and the private key decrypts. With signing, I do not want others to impersonate me. Only I should be able to produce the signature, so the private key signs and the public key verifies.

    Here is another way to say the same thing: Public and private keys form a pair. Either can be used for encryption/decryption in this description; which does what depends on the scenario. In the signing scenario, the private key encrypts and the public key decrypts, allowing all public-key holders to verify the private-key holder’s identity and prevent alteration of the published content. It does not keep the content secret. In the encryption scenario, the public key encrypts and the private key decrypts, allowing information to be sent to the key owner. Others may alter the information, but cannot read it.

    For example, in an encryption scenario: If A wants to send B confidential data securely, both should have private keys. A first encrypts the data with B’s public key, then encrypts that encrypted data with A’s private key before sending it to B. This ensures the content cannot be read or altered.

    This explanation clearly describes using public and private keys together in different scenarios. It comes from this author; thank you for the explanation.

  3. In OAuth 2.0, does the Authorization Server use the public or private key when issuing tokens?

    Answer: the example and Question 2 already explain it. Sign tokens with the private key to establish that the signature comes from the Authorization Server. In OAuth 2.0, the authorization server issues tokens, but token validation can be delegated to other servers to reduce its load. Copy the public key to the server cluster that parses the tokens. This is also shown in the red-highlighted RSA JWS screenshot.

  4. Any recommended reading on digital signatures and content encryption?

    Answer: read explanations on reliable websites. Ruan Yifeng’s website probably has related material, although I have not looked for it. Or use Google more and Baidu less, if at all.

Notes


Share this post:

Previous Post
Reading the Java EE Official Documentation
Next Post
Getting Started with RESTEasy

Comments

Questions, corrections, and experiences are welcome. Sign in with GitHub to comment; both language versions share this discussion.

Comments are available on the live site only.