Introduction

Many applications use JSON Web Tokens (JWT) to allow the client to indicate its identity for further exchange after authentication.

From JWT.IO:


JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA.


JSON Web Token is used to carry information related to the identity and characteristics (claims) of a client. This information is signed by the server in order for it to detect whether it was tampered with after sending it to the client. This will prevent an attacker from changing the identity or any characteristics (for example, changing the role from simple user to admin or change the client login).

This token is created during authentication (is provided in case of successful authentication) and is verified by the server before any processing. It is used by an application to allow a client to present a token representing the user's "identity card" to the server and allow the server to verify the validity and integrity of the token in a secure way, all of this in a stateless and portable approach (portable in the way that client and server technologies can be different including also the transport channel even if HTTP is the most often used).

Token Structure

Token structure example taken from JWT.IO:

[Base64(HEADER)].[Base64(PAYLOAD)].[Base64(SIGNATURE)]

  1. eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
  2. eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.
  3. TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

Chunk 1: Header

  1. {
  2. "alg": "HS256",
  3. "typ": "JWT"
  4. }

Chunk 2: Payload

  1. {
  2. "sub": "1234567890",
  3. "name": "John Doe",
  4. "admin": true
  5. }

Chunk 3: Signature

  1. HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), KEY )

Objective

This cheatsheet provides tips to prevent common security issues when using JSON Web Tokens (JWT) with Java.

The tips presented in this article are part of a Java project that was created to show the correct way to handle creation and validation of JSON Web Tokens.

You can find the Java project here, it uses the official JWT library.

In the rest of the article, the term token refers to the JSON Web Tokens (JWT).

Consideration about Using JWT

Even if a JWT token is "easy" to use and allow to expose services (mostly REST style) in a stateless way, it's not the solution that fits for all applications because it comes with some caveats, like for example the question of the storage of the token (tackled in this cheatsheet) and others…

If your application does not need to be fully stateless, you can consider using traditional session system provided by all web frameworks and follow the advice from the dedicated session management cheat sheet. However, for stateless applications, when well implemented, it's a good candidate.

Issues

None Hashing Algorithm

Symptom

This attack, described here occurs when an attacker alters the token and changes the hashing algorithm to indicate, through, the none keyword, that the integrity of the token has already been verified. As explained in the link above some libraries treated tokens signed with the none algorithm as a valid token with a verified signature, so an attacker can alter the token claims and token will be trusted by the application.

How to Prevent

First, use a JWT library that is not exposed to this vulnerability.

Last, during token validation, explicitly request that the expected algorithm was used.

Implementation Example

  1. // HMAC key - Block serialization and storage as String in JVM memory
  2. private transient byte[] keyHMAC = ...;
  3. ...
  4. //Create a verification context for the token requesting
  5. //explicitly the use of the HMAC-256 hashing algorithm
  6. JWTVerifier verifier = JWT.require(Algorithm.HMAC256(keyHMAC)).build();
  7. //Verify the token, if the verification fail then a exception is throwed
  8. DecodedJWT decodedToken = verifier.verify(token);

Token Sidejacking

Symptom

This attack occurs when a token has been intercepted/stolen by an attacker and they use it to gain access to the system using targeted user identity.

How to Prevent

A way to prevent it is to add a "user context" in the token. A user context will be composed of the following information:

  • A random string that will be generated during the authentication phase. It will be sent to the client as an hardened cookie (flags: HttpOnly + Secure + SameSite + cookie prefixes).
  • A SHA256 hash of the random string will be stored in the token (instead of the raw value) in order to prevent any XSS issues allowing the attacker to read the random string value and setting the expected cookie.

IP addresses should not be used because there are some legitimate situations in which the IP address can change during the same session. For example, when an user accesses an application through their mobile device and the mobile operator changes during the exchange, then the IP address may (often) change. Moreover, using the IP address can potentially cause issues with European GDPR compliancy.

During token validation, if the received token does not contain the right context (for example, if it has been replayed), then it must be rejected.

Implementation example

Code to create the token after successful authentication.

  1. // HMAC key - Block serialization and storage as String in JVM memory
  2. private transient byte[] keyHMAC = ...;
  3. // Random data generator
  4. private SecureRandom secureRandom = new SecureRandom();
  5. ...
  6. //Generate a random string that will constitute the fingerprint for this user
  7. byte[] randomFgp = new byte[50];
  8. secureRandom.nextBytes(randomFgp);
  9. String userFingerprint = DatatypeConverter.printHexBinary(randomFgp);
  10. //Add the fingerprint in a hardened cookie - Add cookie manually because
  11. //SameSite attribute is not supported by javax.servlet.http.Cookie class
  12. String fingerprintCookie = "__Secure-Fgp=" + userFingerprint
  13. + "; SameSite=Strict; HttpOnly; Secure";
  14. response.addHeader("Set-Cookie", fingerprintCookie);
  15. //Compute a SHA256 hash of the fingerprint in order to store the
  16. //fingerprint hash (instead of the raw value) in the token
  17. //to prevent an XSS to be able to read the fingerprint and
  18. //set the expected cookie itself
  19. MessageDigest digest = MessageDigest.getInstance("SHA-256");
  20. byte[] userFingerprintDigest = digest.digest(userFingerprint.getBytes("utf-8"));
  21. String userFingerprintHash = DatatypeConverter.printHexBinary(userFingerprintDigest);
  22. //Create the token with a validity of 15 minutes and client context (fingerprint) information
  23. Calendar c = Calendar.getInstance();
  24. Date now = c.getTime();
  25. c.add(Calendar.MINUTE, 15);
  26. Date expirationDate = c.getTime();
  27. Map<String, Object> headerClaims = new HashMap<>();
  28. headerClaims.put("typ", "JWT");
  29. String token = JWT.create().withSubject(login)
  30. .withExpiresAt(expirationDate)
  31. .withIssuer(this.issuerID)
  32. .withIssuedAt(now)
  33. .withNotBefore(now)
  34. .withClaim("userFingerprint", userFingerprintHash)
  35. .withHeader(headerClaims)
  36. .sign(Algorithm.HMAC256(this.keyHMAC));

Code to validate the token.

  1. // HMAC key - Block serialization and storage as String in JVM memory
  2. private transient byte[] keyHMAC = ...;
  3. ...
  4. //Retrieve the user fingerprint from the dedicated cookie
  5. String userFingerprint = null;
  6. if (request.getCookies() != null && request.getCookies().length > 0) {
  7. List<Cookie> cookies = Arrays.stream(request.getCookies()).collect(Collectors.toList());
  8. Optional<Cookie> cookie = cookies.stream().filter(c -> "__Secure-Fgp"
  9. .equals(c.getName())).findFirst();
  10. if (cookie.isPresent()) {
  11. userFingerprint = cookie.get().getValue();
  12. }
  13. }
  14. //Compute a SHA256 hash of the received fingerprint in cookie in order to compare
  15. //it to the fingerprint hash stored in the token
  16. MessageDigest digest = MessageDigest.getInstance("SHA-256");
  17. byte[] userFingerprintDigest = digest.digest(userFingerprint.getBytes("utf-8"));
  18. String userFingerprintHash = DatatypeConverter.printHexBinary(userFingerprintDigest);
  19. //Create a verification context for the token
  20. JWTVerifier verifier = JWT.require(Algorithm.HMAC256(keyHMAC))
  21. .withIssuer(issuerID)
  22. .withClaim("userFingerprint", userFingerprintHash)
  23. .build();
  24. //Verify the token, if the verification fail then an exception is thrown
  25. DecodedJWT decodedToken = verifier.verify(token);

No Built-In Token Revocation by the User

Symptom

This problem is inherent to JWT because a token only becomes invalid when it expires. The user has no built-in feature to explicitly revoke the validity of a token. This means that if it is stolen, a user cannot revoke the token itself thereby blocking the attacker.

How to Prevent

A way to protect against this is to implement a token blacklist that will be used to mimic the "logout" feature that exists with traditional session management system.

The blacklist will keep a digest (SHA-256 encoded in HEX) of the token with a revocation date. This entry must endure at least until the expiration of the token.

When the user wants to "logout" then it call a dedicated service that will add the provided user token to the blacklist resulting in an immediate invalidation of the token for further usage in the application.

Implementation Example

Blacklist Storage

A database table with the following structure will be used as the central blacklist storage.

  1. create table if not exists revoked_token(jwt_token_digest varchar(255) primary key,
  2. revocation_date timestamp default now());

Token Revocation Management

Code in charge of adding a token to the blacklist and checking if a token is revoked.

  1. /**
  2. * Handle the revocation of the token (logout).
  3. * Use a DB in order to allow multiple instances to check for revoked token
  4. * and allow cleanup at centralized DB level.
  5. */
  6. public class TokenRevoker {
  7. /** DB Connection */
  8. @Resource("jdbc/storeDS")
  9. private DataSource storeDS;
  10. /**
  11. * Verify if a digest encoded in HEX of the ciphered token is present
  12. * in the revocation table
  13. *
  14. * @param jwtInHex Token encoded in HEX
  15. * @return Presence flag
  16. * @throws Exception If any issue occur during communication with DB
  17. */
  18. public boolean isTokenRevoked(String jwtInHex) throws Exception {
  19. boolean tokenIsPresent = false;
  20. if (jwtInHex != null && !jwtInHex.trim().isEmpty()) {
  21. //Decode the ciphered token
  22. byte[] cipheredToken = DatatypeConverter.parseHexBinary(jwtInHex);
  23. //Compute a SHA256 of the ciphered token
  24. MessageDigest digest = MessageDigest.getInstance("SHA-256");
  25. byte[] cipheredTokenDigest = digest.digest(cipheredToken);
  26. String jwtTokenDigestInHex = DatatypeConverter.printHexBinary(cipheredTokenDigest);
  27. //Search token digest in HEX in DB
  28. try (Connection con = this.storeDS.getConnection()) {
  29. String query = "select jwt_token_digest from revoked_token where jwt_token_digest = ?";
  30. try (PreparedStatement pStatement = con.prepareStatement(query)) {
  31. pStatement.setString(1, jwtTokenDigestInHex);
  32. try (ResultSet rSet = pStatement.executeQuery()) {
  33. tokenIsPresent = rSet.next();
  34. }
  35. }
  36. }
  37. }
  38. return tokenIsPresent;
  39. }
  40. /**
  41. * Add a digest encoded in HEX of the ciphered token to the revocation token table
  42. *
  43. * @param jwtInHex Token encoded in HEX
  44. * @throws Exception If any issue occur during communication with DB
  45. */
  46. public void revokeToken(String jwtInHex) throws Exception {
  47. if (jwtInHex != null && !jwtInHex.trim().isEmpty()) {
  48. //Decode the ciphered token
  49. byte[] cipheredToken = DatatypeConverter.parseHexBinary(jwtInHex);
  50. //Compute a SHA256 of the ciphered token
  51. MessageDigest digest = MessageDigest.getInstance("SHA-256");
  52. byte[] cipheredTokenDigest = digest.digest(cipheredToken);
  53. String jwtTokenDigestInHex = DatatypeConverter.printHexBinary(cipheredTokenDigest);
  54. //Check if the token digest in HEX is already in the DB and add it if it is absent
  55. if (!this.isTokenRevoked(jwtInHex)) {
  56. try (Connection con = this.storeDS.getConnection()) {
  57. String query = "insert into revoked_token(jwt_token_digest) values(?)";
  58. int insertedRecordCount;
  59. try (PreparedStatement pStatement = con.prepareStatement(query)) {
  60. pStatement.setString(1, jwtTokenDigestInHex);
  61. insertedRecordCount = pStatement.executeUpdate();
  62. }
  63. if (insertedRecordCount != 1) {
  64. throw new IllegalStateException("Number of inserted record is invalid," +
  65. " 1 expected but is " + insertedRecordCount);
  66. }
  67. }
  68. }
  69. }
  70. }

Token Information Disclosure

Symptom

This attack occurs when an attacker has access to a token (or a set of tokens) and extracts information stored in it (the contents of JWT tokens are base64 encoded, but is not encrypted by default) in order to obtain information about the system. Information can be for example the security roles, login format…

How to Prevent

A way to protect against this attack is to cipher the token using, for example, a symmetric algorithm.

It's also important to protect the ciphered data against attack like Padding Oracle or any other attack using cryptanalysis.

In order to achieve all these goals, the AES-GCM algorithm is used which provides Authenticated Encryption with Associated Data.

More details from here:

  1. AEAD primitive (Authenticated Encryption with Associated Data) provides functionality of symmetric
  2. authenticated encryption.
  3. Implementations of this primitive are secure against adaptive chosen ciphertext attacks.
  4. When encrypting a plaintext one can optionally provide associated data that should be authenticated
  5. but not encrypted.
  6. That is, the encryption with associated data ensures authenticity (ie. who the sender is) and
  7. integrity (ie. data has not been tampered with) of that data, but not its secrecy.
  8. See RFC5116: https://tools.ietf.org/html/rfc5116

Note:

Here ciphering is added mainly to hide internal information but it's very important to remember that the first protection against tampering of the JWT token is the signature. So, the token signature and its verification must be always in place.

Implementation Example

Token Ciphering

Code in charge of managing the ciphering. Google Tink dedicated crypto library is used to handle ciphering operations in order to use built-in best practices provided by this library.

  1. /**
  2. * Handle ciphering and deciphering of the token using AES-GCM.
  3. *
  4. * @see "https://github.com/google/tink/blob/master/docs/JAVA-HOWTO.md"
  5. */
  6. public class TokenCipher {
  7. /**
  8. * Constructor - Register AEAD configuration
  9. *
  10. * @throws Exception If any issue occur during AEAD configuration registration
  11. */
  12. public TokenCipher() throws Exception {
  13. AeadConfig.register();
  14. }
  15. /**
  16. * Cipher a JWT
  17. *
  18. * @param jwt Token to cipher
  19. * @param keysetHandle Pointer to the keyset handle
  20. * @return The ciphered version of the token encoded in HEX
  21. * @throws Exception If any issue occur during token ciphering operation
  22. */
  23. public String cipherToken(String jwt, KeysetHandle keysetHandle) throws Exception {
  24. //Verify parameters
  25. if (jwt == null || jwt.isEmpty() || keysetHandle == null) {
  26. throw new IllegalArgumentException("Both parameters must be specified!");
  27. }
  28. //Get the primitive
  29. Aead aead = AeadFactory.getPrimitive(keysetHandle);
  30. //Cipher the token
  31. byte[] cipheredToken = aead.encrypt(jwt.getBytes(), null);
  32. return DatatypeConverter.printHexBinary(cipheredToken);
  33. }
  34. /**
  35. * Decipher a JWT
  36. *
  37. * @param jwtInHex Token to decipher encoded in HEX
  38. * @param keysetHandle Pointer to the keyset handle
  39. * @return The token in clear text
  40. * @throws Exception If any issue occur during token deciphering operation
  41. */
  42. public String decipherToken(String jwtInHex, KeysetHandle keysetHandle) throws Exception {
  43. //Verify parameters
  44. if (jwtInHex == null || jwtInHex.isEmpty() || keysetHandle == null) {
  45. throw new IllegalArgumentException("Both parameters must be specified !");
  46. }
  47. //Decode the ciphered token
  48. byte[] cipheredToken = DatatypeConverter.parseHexBinary(jwtInHex);
  49. //Get the primitive
  50. Aead aead = AeadFactory.getPrimitive(keysetHandle);
  51. //Decipher the token
  52. byte[] decipheredToken = aead.decrypt(cipheredToken, null);
  53. return new String(decipheredToken);
  54. }
  55. }

Creation / Validation of the Token

Use the token ciphering handler during the creation and the validation of the token.

Load keys (ciphering key was generated and stored using Google Tink) and setup cipher.

  1. //Load keys from configuration text/json files in order to avoid to storing keys as a String in JVM memory
  2. private transient byte[] keyHMAC = Files.readAllBytes(Paths.get("src", "main", "conf", "key-hmac.txt"));
  3. private transient KeysetHandle keyCiphering = CleartextKeysetHandle.read(JsonKeysetReader.withFile(
  4. Paths.get("src", "main", "conf", "key-ciphering.json").toFile()));
  5. ...
  6. //Init token ciphering handler
  7. TokenCipher tokenCipher = new TokenCipher();

Token creation.

  1. //Generate the JWT token using the JWT API...
  2. //Cipher the token (String JSON representation)
  3. String cipheredToken = tokenCipher.cipherToken(token, this.keyCiphering);
  4. //Send the ciphered token encoded in HEX to the client in HTTP response...

Token validation.

  1. //Retrieve the ciphered token encoded in HEX from the HTTP request...
  2. //Decipher the token
  3. String token = tokenCipher.decipherToken(cipheredToken, this.keyCiphering);
  4. //Verify the token using the JWT API...
  5. //Verify access...

Token Storage on Client Side

Symptom

This occurs when an application stores the token in a manner exhibiting the following behavior:

  • Automatically sent by the browser (Cookie storage).
  • Retrieved even if the browser is restarted (Use of browser localStorage container).
  • Retrieved in case of XSS issue (Cookie accessible to JavaScript code or Token stored in browser local/session storage).

How to Prevent

  • Store the token using the browser sessionStorage container.
  • Add it as a Bearer HTTP Authentication header with JavaScript when calling services.
  • Add fingerprint information to the token.
    By storing the token in browser sessionStorage container it exposes the token to being stolen through a XSS attack. However, fingerprints added to the token prevent reuse of the stolen token by the attacker on their machine. To close a maximum of exploitation surfaces for an attacker, add a browser Content Security Policy to harden the execution context.

Note:

  • The remaining case is when an attacker uses the user's browsing context as a proxy to use the target application through the legitimate user but the Content Security Policy can prevent communication with non expected domains.
  • It's also possible to implement the authentication service in a way that the token is issued within a hardened cookie, but in this case, protection against a Cross-Site Request Forgery attack must be implemented.

Implementation Example

JavaScript code to store the token after authentication.

  1. /* Handle request for JWT token and local storage*/
  2. function authenticate() {
  3. const login = $("#login").val();
  4. const postData = "login=" + encodeURIComponent(login) + "&password=test";
  5. $.post("/services/authenticate", postData, function (data) {
  6. if (data.status == "Authentication successful!") {
  7. ...
  8. sessionStorage.setItem("token", data.token);
  9. }
  10. else {
  11. ...
  12. sessionStorage.removeItem("token");
  13. }
  14. })
  15. .fail(function (jqXHR, textStatus, error) {
  16. ...
  17. sessionStorage.removeItem("token");
  18. });
  19. }

JavaScript code to add the token as a Bearer HTTP Authentication header when calling a service, for example a service to validate token here.

  1. /* Handle request for JWT token validation */
  2. function validateToken() {
  3. var token = sessionStorage.getItem("token");
  4. if (token == undefined || token == "") {
  5. $("#infoZone").removeClass();
  6. $("#infoZone").addClass("alert alert-warning");
  7. $("#infoZone").text("Obtain a JWT token first :)");
  8. return;
  9. }
  10. $.ajax({
  11. url: "/services/validate",
  12. type: "POST",
  13. beforeSend: function (xhr) {
  14. xhr.setRequestHeader("Authorization", "bearer " + token);
  15. },
  16. success: function (data) {
  17. ...
  18. },
  19. error: function (jqXHR, textStatus, error) {
  20. ...
  21. },
  22. });
  23. }

Weak Token Secret

Symptom

When the token is protected using an HMAC based algorithm, the security of the token is entirely dependent on the strength of the secret used with the HMAC. If an attacker can obtain a valid JWT, they can then carry out an offline attack and attempt to crack the secret using tools such as John the Ripper or Hashcat.

If they are successful, they would then be able to modify the token and re-sign it with the key they had obtained. This could let them escalate their privileges, compromise other users' accounts, or perform other actions depending on the contents of the JWT.

There are a number of guides that document this process in greater detail.

How to Prevent

The simplest way to prevent this attack is to ensure that the secret used to sign the JWTs is strong and unique, in order to make it harder for an attacker to crack. As this secret would never need to be typed by a human, it should be at least 64 characters, and generated using a secure source of randomness.

Alternatively, consider the use of tokens that are signed with RSA rather than using an HMAC and secret key.

Further Reading