JSON Web Token (JWT) Cheat Sheet for Java

Last revision (mm/dd/yy): //

= Introduction =

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

From https://jwt.io/introduction:

''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.''

= Objective =

This article have for objective to provide several tips in a form of a cheats sheet, for Java technology, in order to prevent common security issues meet when using JWT.

About the code samples provided in tips, a global java project sample has been created in order to show the creation and the validatin of a token in a secure way.

This project is available here and it use official JWT library with Crypto-JS library.

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

= Issues =

Symptom
This attack, described here occur when a attacker alter the token and change 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 tkey 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
// HMAC key - Block serialization and storage as String in JVM memory private transient byte[] keyHMAC = ...;

...

//Create a verification context for the token requesting explicitly the use of the HMAC-256 hashing algorithm JWTVerifier verifier = JWT.require(Algorithm.HMAC256(keyHMAC)).build;

//Verify the token, if the verification fail then a exception is throwed DecodedJWT decodedToken = verifier.verify(token);

Symptom
This attack occur when a token has been intercepted/stolen by a attacker and this one use it to gain access to the system using targeted user identity.

How to prevent
A way to protect, is to add "user context" in the token. User context here can be composed by the following information:


 * Browser unique fingerprint.
 * IP address: : Use this context information when the target users are expected to use the site/service for a "single objective" and during a "short" amount of time like for example a wire transfer in a bank (connect, perform the transfer and disconnect). There some situation in which IP address can change during the same session like for example when a user access an application through his mobile and he change mobile operator then he change legitimately (often) is IP address. In the same way don’t use only the IP address because this context information will not uniquely identify a user if he access the application through a proxy (for example a corporate proxy).

During token validation, if the received token do not contains the rights context so, it is replayed and then it must be rejected.

Client side: Generate a browser unique fingerprint
JavaScript code to generate the browser fingerprint.

/* Generate a fingerprint string for the browser */ function generateFingerprint{ //Generate a string based on "stable" information taken from the browser //We call here "stable information", information that normally don't change during the user //browse the application just after authentication var fingerprint = [];

//Take plugins for(var i = 0; i < navigator.plugins.length; i++){ fingerprint.push(navigator.plugins[i].name); fingerprint.push(navigator.plugins[i].filename); fingerprint.push(navigator.plugins[i].description); fingerprint.push(navigator.plugins[i].version); }

//Take User Agent fingerprint.push(navigator.userAgent);

//Take Screen resolution fingerprint.push(screen.availHeight); fingerprint.push(screen.availWidth); fingerprint.push(screen.colorDepth); fingerprint.push(screen.height); fingerprint.push(screen.pixelDepth); fingerprint.push(screen.width);

//Take Graphical card info //See http://output.jsbin.com/ovekor/3/ try { //Add a Canvas element if the body do not contains one if ( $("#glcanvas").length == 0 ){ $(document.body).append(" "); }       //Get ref on Canvas var canvas = document.getElementById("glcanvas"); //Retrieve Canvas properties gl = canvas.getContext("experimental-webgl"); gl.viewportWidth = canvas.width; gl.viewportHeight = canvas.height; fingerprint.push(gl.getParameter(gl.VERSION)); fingerprint.push(gl.getParameter(gl.SHADING_LANGUAGE_VERSION)); fingerprint.push(gl.getParameter(gl.VENDOR)); fingerprint.push(gl.getParameter(gl.RENDERER)); fingerprint.push(gl.getSupportedExtensions.join); } catch (e) { //Get also error because it's will be stable too.. fingerprint.push(e); }

//Last and, in order to made this browser unique, generate a random ID that we will store //in local storage (in order to be persistent after browser close/reopen) //Add this ID because, in Enterprise, most of the time browser have the same configuration var browserUniqueID = localStorage.getItem("browserUniqueID"); if (browserUniqueID === null) { localStorage.setItem("browserUniqueID", CryptoJS.lib.WordArray.random(80)); browserUniqueID = localStorage.getItem("browserUniqueID"); }   fingerprint.push(browserUniqueID);

return fingerprint.join; }

JavaScript code to generate a hash of the fingerprint that will be sent to the server for each request. A hash is send in order to avoid to send user browser properties on the wire.

//Call the fingerprint dedicated function var fingerprint = generateFingerprint; //Use CryptoJS library ot generate a hex encoded string of the hash of the fingerprint var fingerprintHash = CryptoJS.SHA256(fingerprint);

Server side: Token creation and validation
Code to extract the client IP.

Remark:

An issue exists in this implementation proposal about the retrieving of the client IP. Indeed, if the header X-Forwarded-For is present then the method retrieveClientIP will get the client IP from this header according to the header expected content.

The issue occur when the Web Application Firewall append the existing header X-Forwarded-For of the incoming request (add the client real network IP at the end of the list) instead of overwriting the header content with the real network IP of the incoming request. In this way, an attacker can add the header X-Forwarded-For in his request and set an arbitrary IP that will be taken as the client IP by the the method retrieveClientIP.

If you don't have any Web Application Firewall or Reverse Proxy in front of your application then use directly the IP from the HttpServletRequest instance.

/** private String retrieveClientIP(HttpServletRequest request) throws IllegalArgumentException{ String address; //Get IP from X-Forwarded-For header or from the request object directly String ip = request.getHeader("X-Forwarded-For"); if(ip != null){ if(ip.contains(",")){ ip = ip.split(",")[0]; }	   address = ip.trim; }else{ address = request.getRemoteAddr; }	//Validate IP format //"InetAddressValidator" came from "commons-validator" API if(!InetAddressValidator.getInstance.isValid(address)){ throw new IllegalArgumentException("Invalid IP address !"); }
 * Return the client IP address
 * @param request Incoming HTTP request
 * @return The client IP address
 * @throws IllegalArgumentException If the IP retrieved is invalid
 * @see "https://en.wikipedia.org/wiki/X-Forwarded-For"

return address; }

Code to create the token after success authentication.

// HMAC key - Block serialization and storage as String in JVM memory private transient byte[] keyHMAC = ...;

...

//Create the token with a validity of 15 minutes and client context (IP + Browser fingerprint SHA256 digest HEX encoded) information Calendar c = Calendar.getInstance; Date now = c.getTime; c.add(Calendar.MINUTE, 15); Date expirationDate = c.getTime; Map headerClaims = new HashMap<>; headerClaims.put("typ", "JWT"); String token = JWT.create.withSubject(login) .withExpiresAt(expirationDate) .withIssuer(issuerID) .withIssuedAt(now) .withNotBefore(now) .withClaim("clientIP", retrieveClientIP(request)) .withClaim("browserFingerprintDigest", browserFingerprintDigest) .withHeader(headerClaims) .sign(Algorithm.HMAC256(keyHMAC));

Code to validate the token.

// HMAC key - Block serialization and storage as String in JVM memory private transient byte[] keyHMAC = ...;

...

//Create a verification context for the token JWTVerifier verifier = JWT.require(Algorithm.HMAC256(keyHMAC)) .withIssuer(issuerID) .withClaim("clientIP", retrieveClientIP(request)) .withClaim("browserFingerprintDigest", browserFingerprintDigest) .build;

//Verify the token, if the verification fail then a exception is throwed DecodedJWT decodedToken = verifier.verify(token);

Symptom
This attack occur when a attacker access to a token (or a set of tokens) and extract information stored into it (JWT token information are base64 encoded at the basis) in order to obtains information about the system. Information can be for example the security roles, login format...

How to prevent
A way to protect, is to cipher the token using for example a symetric 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 algorithm AES-GCM can be used in conjunction with Additional Authentication Data (AAD) feature.

A database can be used to store the NONCE and the AAD associated to a token.

Token ciphering
Database structure.

create table if not exists nonce(jwt_token_digest varchar(255) primary key, gcm_nonce varchar(255) not null unique, gcm_aad varchar(255) not null unique); create index if not exists idx_nonce on nonce(gcm_nonce);

Code in charge of managing the ciphering.

/** * Handle ciphering and deciphering of the token using AES-GCM. * Use a DB in order to link a GCM NONCE to a ciphered message and ensure that a NONCE is never reused * and also allow use of several application nodes in load balancing. */ public class TokenCipher {

/** AES-GCM parameters */ private static final int GCM_NONCE_LENGTH = 12; // in bytes

/** AES-GCM parameters */ private static final int GCM_TAG_LENGTH = 16; // in bytes

/**Secure random generator */ private final SecureRandom secRandom = new SecureRandom;

/** DB Connection */ @Resource("jdbc/storeDS") private DataSource storeDS;

/**    * Cipher a JWT * @param jwt Token to cipher * @param key Ciphering key * @return The ciphered version of the token encoded in HEX * @throws Exception If any issue occur during token ciphering operation */   public String cipherToken(String jwt, byte[] key) throws Exception { //Verify parameters if(jwt == null || jwt.isEmpty || key == null || key.length == 0){ throw new IllegalArgumentException("Both parameters must be specified !"); }

//Generate a NONCE //NOTE: As in the DB, the column to store the NONCE is flagged UNIQUE then the insert will fail //if the NONCE already exists, normally as we use the Java Secure Random implementation //it will never happen. final byte[] nonce = new byte[GCM_NONCE_LENGTH]; secRandom.nextBytes(nonce);

//Prepare ciphering key from bytes provided SecretKey aesKey = new SecretKeySpec(key, 0, key.length, "AES");

//Setup Cipher Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding", "SunJCE"); GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, nonce); cipher.init(Cipher.ENCRYPT_MODE, aesKey, spec);

//Add "Additional Authentication Data" (AAD) in order to operate in AEAD mode - Generate it       byte[] aad = new byte[32]; secRandom.nextBytes(aad); cipher.updateAAD(aad);

//Cipher the token byte[] cipheredToken = cipher.doFinal(jwt.getBytes("utf-8"));

//Compute a SHA256 of the ciphered token MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] cipheredTokenDigest = digest.digest(cipheredToken);

//Store GCM NONCE and GCM AAD this.storeNonceAndAAD(DatatypeConverter.printHexBinary(nonce), DatatypeConverter.printHexBinary(aad),       DatatypeConverter.printHexBinary(cipheredTokenDigest));

return DatatypeConverter.printHexBinary(cipheredToken); }

/**    * Decipher a JWT * @param jwtInHex Token to decipher encoded in HEX * @param key Ciphering key * @return The token in clear text * @throws Exception If any issue occur during token deciphering operation */   public String decipherToken(String jwtInHex, byte[] key) throws Exception{ //Verify parameters if(jwtInHex == null || jwtInHex.isEmpty || key == null || key.length == 0){ throw new IllegalArgumentException("Both parameters must be specified !"); }

//Decode the ciphered token byte[] cipheredToken = DatatypeConverter.parseHexBinary(jwtInHex);

//Compute a SHA256 of the ciphered token MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] cipheredTokenDigest = digest.digest(cipheredToken);

//Read the GCM NONCE and GCM AAD associated from the DB       Map gcmInfos = this.readNonceAndAAD(DatatypeConverter.printHexBinary(cipheredTokenDigest)); if(gcmInfos == null){ throw new Exception("Cannot found a NONCE and AAD associated to the token provided !"); }       byte[] nonce = DatatypeConverter.parseHexBinary(gcmInfos.get("NONCE")); byte[] aad = DatatypeConverter.parseHexBinary(gcmInfos.get("AAD"));

//Prepare ciphering key from bytes provided SecretKey aesKey = new SecretKeySpec(key, 0, key.length, "AES");

//Setup Cipher Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding", "SunJCE"); GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, nonce); cipher.init(Cipher.DECRYPT_MODE, aesKey, spec);

//Add "Additional Authentication Data" (AAD) in order to operate in AEAD mode cipher.updateAAD(aad);

//Decipher the token byte[] decipheredToken = cipher.doFinal(cipheredToken);

return new String(decipheredToken); }

/**    * Store GCM NONCE and GCM AAD in the DB     * @param nonceInHex Nonce encoded in HEX * @param aadInHex AAD encoded in HEX * @param jwtTokenDigestInHex SHA256 of the JWT ciphered token encoded in HEX * @throws Exception If any issue occur during communication with DB    */ private void storeNonceAndAAD(String nonceInHex, String aadInHex, String jwtTokenDigestInHex) throws Exception { try (Connection con = this.storeDS.getConnection) { String query = "insert into nonce(jwt_token_digest, gcm_nonce, gcm_aad) values(?, ?, ?)"; int insertedRecordCount; try (PreparedStatement pStatement = con.prepareStatement(query)) { pStatement.setString(1, jwtTokenDigestInHex); pStatement.setString(2, nonceInHex); pStatement.setString(3, aadInHex); insertedRecordCount = pStatement.executeUpdate; }           if (insertedRecordCount != 1) { throw new IllegalStateException("Number of inserted record is invalid, 1 expected but is " + insertedRecordCount); }       }    }

/**    * Read GCM NONCE and GCM AAD from the DB     * @param jwtTokenDigestInHex SHA256 of the JWT ciphered token encoded in HEX for which we must read the NONCE and AAD * @return A dict containing the NONCE and AAD if they exists for the specified token * @throws Exception If any issue occur during communication with DB    */ private Map readNonceAndAAD(String jwtTokenDigestInHex) throws Exception{ Map gcmInfos = null; try (Connection con = this.storeDS.getConnection) { String query = "select gcm_nonce, gcm_aad from nonce where jwt_token_digest = ?"; try (PreparedStatement pStatement = con.prepareStatement(query)) { pStatement.setString(1, jwtTokenDigestInHex); try (ResultSet rSet = pStatement.executeQuery) { while (rSet.next) { gcmInfos = new HashMap<>(2); gcmInfos.put("NONCE", rSet.getString(1)); gcmInfos.put("AAD", rSet.getString(2)); }               }            }        }

return gcmInfos; }

}

Creation / Validation of the token
Use of the token ciphering during the creation and the validation of the token.

Load keys and setup cipher.

//Load keys from configuration text files in order to avoid to store keys as String in JVM memory private transient byte[] keyHMAC = Files.readAllBytes(Paths.get("key-hmac.txt")); private transient byte[] keyCiphering = Files.readAllBytes(Paths.get("key-ciphering.txt"));

//Load issuer ID from configuration text file private transient String issuerID = Files.readAllLines(Paths.get("issuer-id.txt")).get(0);

//Init token ciphering handler TokenCipher tokenCipher = new TokenCipher;

Token creation.

//Create the token with a validity of 15 minutes and client context (IP + Browser fingerprint SHA256 digest HEX encoded) information Calendar c = Calendar.getInstance; Date now = c.getTime; c.add(Calendar.MINUTE, 15); Date expirationDate = c.getTime; Map headerClaims = new HashMap<>; headerClaims.put("typ", "JWT"); String token = JWT.create.withSubject(login) .withExpiresAt(expirationDate) .withIssuer(issuerID) .withIssuedAt(now) .withNotBefore(now) .withClaim("clientIP", retrieveClientIP(request)) .withClaim("browserFingerprintDigest", browserFingerprintDigest) .withHeader(headerClaims) .sign(Algorithm.HMAC256(keyHMAC)); //Cipher the token String cipheredToken = tokenCipher.cipherToken(token, keyCiphering);

Token validation.

//Decipher the token String token = tokenCipher.decipherToken(cipheredToken, keyCiphering);

//Create a verification context for the token JWTVerifier verifier = JWT.require(Algorithm.HMAC256(this.keyHMAC)) .withIssuer(issuerID) .withClaim("clientIP", retrieveClientIP(request)) .withClaim("browserFingerprintDigest", browserFingerprintDigest) .build;

//Verify the token, if the verification fail then a exception is throwed DecodedJWT decodedToken = verifier.verify(token);

Symptom
It's occur when a application store the token in a way allowing this one:


 * To be automatically sent by the browser (Cookie storage).
 * To be retrieved even if the browser is restarted (Use of localStorage container).

How to prevent
Store the token using the browser sessionStorage container and add it as a Bearer with JavaScript when calling service.

Implementation example
JavaScript code to store the token after authentication.

/* Handle request for JWT token and local storage*/ function getToken{ var login = $("#login").val; var password = $("#password").val; var fingerprint = generateFingerprint; var fingerprintHash = CryptoJS.SHA256(fingerprint); var postData = "login=" + encodeURIComponent(login) + "&password=" + encodeURIComponent(password) +"&browserFingerprintDigest=" + encodeURIComponent(fingerprintHash);

$.post("/services/authenticate", postData,function (data){       if(data.status == "Authentication successful !"){	                sessionStorage.setItem("token", data.token);        }else{            sessionStorage.removeItem("token");        }    }) .fail(function(jqXHR, textStatus, error){       sessionStorage.removeItem("token");    }); }

JavaScript code to add the token as Beader when calling a service, for example a service to validate token here.

/* Handle request for JWT token validation */ function validateToken{ var token = sessionStorage.getItem("token"); var fingerprint = generateFingerprint; var fingerprintHash = CryptoJS.SHA256(fingerprint); var postData = "browserFingerprintDigest=" + encodeURIComponent(fingerprintHash);

$.ajax({       url: "/services/validate",        type: "POST",        beforeSend: function(xhr) {            xhr.setRequestHeader("Authorization", "bearer " + token);        },        data: postData,        success: function(data) {		...        },        error: function(jqXHR, textStatus, error) {		...        },    }); }

= Authors and Primary Editors =

Dominique Righetto - dominique.righetto@owasp.org

= Other Cheatsheets =