JWT, JWS, JWE

JWT (JSON Web Token) adalah standar yang digunakan untuk komunikasi antara client & server pada proses Authentication & Authorization. JWT punya 2 pilihan token. Pertama JWS (JSON Web Signature), yaitu informasi yang ada di token di-encode menggunakan Base64. Kedua JWE (JSON Web Encryption), yaitu informasi yang ada di token di-encrypt dengan algoritma tertentu. Cara generate dan verifikasinya pun ada 2, bisa menggunakan Secret Key yang sama, bisa juga menggunakan pasangan Public Key & Private Key. JWT ini stateless karena untuk verifikasi data bisa langsung dari payload yang ada di dalamnya tanpa perlu query ke database.
JWT di Java
Pada Java kita bisa menggunakan library seperti jjwt untuk Authorization. Menurut gw ini library yang cukup simple dan banyak digunakan.
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt-api.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt-api.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt-api.version}</version>
<scope>runtime</scope>
</dependency>JWS
JWS adalah jenis JWT yang paling populer. JWS memastikan token hanya bisa diterbitkan oleh pihak penerbit yang sah. Analoginya seperti undangan yang ditandatangani oleh Presiden. Undangan tersebut diverifikasi keasliannya berdasarkan tanda tangan Presiden yang ada di undangan tersebut. Orang yang memiliki undangan tersebut bebas berkirim surat ke istana selama tanda tangan Presiden di undangan tersebut sah.
Token JWS terdiri dari 3 bagian yang dipisahkan oleh simbol titik (.), yaitu Header, Payload, dan Signature:
- Header: berisi info algoritma yang dipakai, tipe JWT, dan hal-hal teknis lainnya yang di-encode;
- Payload: berisi data yang di-encode, seperti waktu expire (exp), penerbit token (iss), target client (aud), user roles, scopes, serta info-info penting terkait user yang bisa di-custom sesuai kebutuhan untuk verifikasi yang bukan data sensitif karena ini hal yang bisa dibaca;
- Signature: adalah hash menggunakan key dari header & payload sehingga value dari JWT ini ga bisa diubah dari luar selama key yang digunakan ga bocor;
${Base64(header)}.${Base64(payload)}.${Base64(hash(header + payload + key))}Setelah berhasil Authentication, nanti server akan membungkus header JWT dan payload untuk di-encode menggunakan base64. Lalu itu di-hash dengan Secret Key atau Private Key yang dipakai dan itu dijadikan sebagai signature. Ini yang akan dikirim ke browser dan akan digunakan browser di tiap request untuk Authorization. Saat request diterima server maka header & payload di-hash ulang dengan Secret Key lalu dibandingkan dengan JWT yang dikirim browser atau diverifikasi menggunakan Public Key. Jika valid berarti Authorization berhasil.
JWS + Secret Key
Kita bisa membuat signature menggunakan Secret Key. Jadi, saat Authentication & Authorization key yang dipakai adalah key yang sama. Ini ibarat undangan yang ditandatangani Presiden dan diverifikasi oleh Presiden itu sendiri. Kombinasi ini adalah pilihan yang komputasinya paling cepat dan ga ribet. Kekurangannya, kalau Secret Key bocor maka hacker bisa bikin token sendiri tanpa perlu Authentication. Ini cocok untuk aplikasi yang tempat Authentication & Authorization di server yang sama. Kalau Authentication & Authorization di tempat berbeda artinya Secret Key yang sama bisa dibaca di beberapa server sehingga peluang bocornya lebih tinggi.
Sekarang kita coba bikin codenya. Pertama kita perlu generate Secret Key terlebih dulu. Sebenarnya tulis String sendiri juga bisa sih, tapi sebaiknya ini di-generate biar lebih aman. Kita akan generate menggunakan algoritma HmacSHA256 berukuran 256 bit untuk kriptografi satu arah.
public static void generateSecretKey() throws NoSuchAlgorithmException {
KeyGenerator keyGenerator = KeyGenerator.getInstance("HmacSHA256");
keyGenerator.init(256);
SecretKey secretKey = keyGenerator.generateKey();
String secretKeyStr = Base64.getEncoder().encodeToString(secretKey.getEncoded());
System.out.println("secretKeyStr = " + secretKeyStr);
}Setelah di-generate Secret Key tersebut disimpan di tempat yang aman seperti Secret Manager khusus security, Vault, environment variables, atau sejenisnya. Secret Key yang sama akan digunakan untuk Authentication & Authorization.
public static String generateJwtToken(String username, SecretKey secretKey) {
Map<String, String> claims = Maps.of("role", "superuser").and("realName", "ferry").build();
return Jwts.builder()
.subject(username)
.claims(claims)
.expiration(new Date(System.currentTimeMillis() + (30 * 60 * 1000)))
.signWith(secretKey)
.compact();
}
public static Jws<Claims> parseJwtToken(String token, SecretKey secretKey) {
if(token == null || token.isBlank()){
return null;
}
try {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
} catch (Exception e) {
return null;
}
}
private static void jwsPublic(){
String secretKeyStr = getSecretKeyStr();
SecretKey secretKey = new SecretKeySpec(Base64.getDecoder().decode(secretKeyStr), "HmacSHA256");
String jwtToken = generateJwtToken("ferry", secretKey);
System.out.println("jwtToken = " + jwtToken);
Jws<Claims> claims = parseJwtToken(jwtToken, secretKey);
System.out.println("claims = " + claims);
}Dari Secret Key tadi, kita akan bungkus kembali ke objek SecretKey, lalu generate tokennya. Ini yang akan disimpan oleh web nanti. Saat Authorization, web akan kembali mengirim token tersebut dan kita verifikasi lewat parseJwtToken() menggunakan SecretKey yang sama. Jika valid maka ga akan ada error dan kita akan mendapatkan isi payload untuk validasi data lebih lanjut.
JWS + Public/Private Key
Di sini saat Authentication kita akan sign menggunakan Private Key. Saat Authorization kita akan verifikasi menggunakan Public Key. Dengan begini peluang key bocor lebih kecil karena beda key. Kekurangannya, komputasinya sedikit lebih lambat dibanding menggunakan Secret Key. Ini cocok untuk aplikasi yang Authentication & Authorization dilakukan di tempat berbeda. Private Key hanya disimpan di server Authentication aja, sedangkan Public Key disimpan di server yang memerlukan Authorization. Fungsi dari Public Key hanya untuk verifikasi. Meskipun itu kita sebar ke berbagai server maka ga akan ngaruh karena token hanya bisa di-sign menggunakan Private Key. Ini ibarat undangan yang ditandatangani Presiden, tapi bisa diverifikasi tanda tangannya oleh orang-orang kepercayaan Presiden.
Untuk penggunaannya kita perlu generate pasangan Private Key dan Public Key dulu menggunakan algoritma EC berukuran 521 bit.
private static void generateKeyPair() throws NoSuchAlgorithmException{
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
kpg.initialize(521);
KeyPair keyPair = kpg.generateKeyPair();
PublicKey publicKey = keyPair.getPublic();
System.out.println("publicKey = " + Base64.getEncoder().encodeToString(publicKey.getEncoded()));
PrivateKey privateKey = keyPair.getPrivate();
System.out.println("privateKey = " + Base64.getEncoder().encodeToString(privateKey.getEncoded()));
}Oh ya, Key ini harus berpasangan dan pasangannya jangan sampai hilang. Kalau salah satu hilang maka token jadi ga bisa digunakan. Private Key disimpan di server Authentication untuk sign token saat generate, sedangkan Public Key disebar ke server yang memerlukan Authorization untuk verifikasi.
public static String generateJwtToken(String username, PrivateKey privateKey) {
Map<String, String> claims = Maps.of("role", "superuser").and("realName", "ferry").build();
return Jwts.builder()
.subject(username)
.claims(claims)
.expiration(new Date(System.currentTimeMillis() + (30 * 60 * 1000)))
.signWith(privateKey)
.compact();
}
public static Jws<Claims> parseJwtToken(String token, PublicKey publicKey) {
if(token == null || token.isBlank()){
return null;
}
try {
return Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token);
} catch (Exception e) {
return null;
}
}
private static void jwsPrivate() throws NoSuchAlgorithmException, InvalidKeySpecException{
KeyFactory keyFactory = KeyFactory.getInstance("EC");
String privateKeyStr = getPrivateKeyStr();
PrivateKey privateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyStr)));
String jwtToken = generateJwtToken("ferry", privateKey);
System.out.println("jwtToken = " + jwtToken);
String publicKeyStr = getPublicKeyStr();
PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyStr)));
Jws<Claims> claims = parseJwtToken(jwtToken, publicKey);
System.out.println("claims = " + claims);
}Dari Private Key tadi kita akan bungkus kembali ke objek PrivateKey, lalu generate tokennya. Token ini akan disimpan oleh aplikasi. Saat Authorization, aplikasi akan kembali mengirim token tersebut. Di server Authorization, Public Key tadi akan dibungkus kembali ke objek PublicKey lalu dipakai untuk verifikasi. Jika valid maka ga akan ada error dan kita akan mendapatkan isi payload untuk validasi data lebih lanjut.
JWE
JWE adalah alternatif dari JWS. Di JWS, payload hanya di-encode dengan Base64 sehingga bisa dibaca. Ini ga aman kalau ada data sensitif di dalamnya. Solusinya dengan JWE adalah payload dienkripsi. JWE memastikan token hanya bisa dibaca oleh pihak penerima yang sah. Analoginya seperti mengirim surat untuk Presiden yang disimpan ke sebuah kotak yang digembok. Presiden membagikan gembok miliknya kepada pengirim. Orang yang mendapat gembok tersebut bebas berkirim surat ke Presiden. Saat ingin mengirimkan surat, maka surat tersebut akan disimpan ke sebuah kotak dan si pengirim akan menguncinya menggunakan gembok tersebut. Setelah dikunci ga ada yang bisa membukanya selain Presiden.
JWE terdiri dari 5 bagian yang juga dipisahkan simbol titik dan semuanya di-encode dengan Base64:
- Header: berisi info algoritma yang dipakai, tipe enkripsi yang digunakan, tipe JWT, dan hal-hal teknis lainnya;
- CEK (Content Encryption Key): ini adalah karakter yang di-generate dan dienkripsi untuk mengunci payload, ini fungsinya seperti password;
- Initialization Vector (Random Bytes): ini adalah bytes yang di-generate random agar hasil enkripsinya ga bisa ditebak meskipun menggunakan data yang sama;
- Ciphertext: ini adalah data payload yang dienkripsi menggunakan algoritma yang dipilih dan dikunci menggunakan CEK dan Initialization Vector yang ada di token;
- Authentication Tag: ini semacam checksum dari Header, CEK, Initialization Vector, dan Ciphertext untuk menjamin integritas token ga bisa diubah dari luar;
${Base64(header)}.${Base64(encrypt(CEK + key))}.${Base64(random)}.${Base64(encrypt(payload + CEK + random + key))}.${Base64(checksum(ciphertext + random + CEK + header))}Setelah berhasil Authentication, nanti server akan membungkus header JWT di-encode menggunakan base64. Lalu generate CEK, ini adalah password enkripsi. CEK juga akan dienkripsi pada token menggunakan Secret Key atau Public Key. Selanjutnya generate random bytes sebagai Initialization Vector. Kemudian payload dienkripsi sebagai Ciphertext sesuai algoritma enkripsi yang dipakai menggunakan Secret Key atau Public Key dan dikunci dengan CEK + Initialization Vector. Terakhir generate checksum dari Header, CEK, Initialization Vector, dan Ciphertext sebagai Authentication Tag untuk menjamin data ga bisa diubah dari luar. Ini yang akan dikirim ke browser dan akan digunakan browser di tiap request untuk Authorization. Saat request diterima server maka akan diverifikasi dengan cara mengekstrak CEK yang terenkripsi dari token lalu CEK didekripsi menggunakan Secret Key atau Private Key. Kemudian digenerate ulang checksum dari Header, CEK yang sudah didekripsi, Initialization Vector, dan Ciphertext, jika sama dengan Authentication Tag pada token artinya token tersebut valid. Selanjutnya dekripsi Ciphertext menjadi payload kembali menggunakan Secret Key atau Private Key dan buka kuncinya menggunakan CEK. Jika terbuka berarti Authorization berhasil.
JWE + Secret Key
Di sini Secret Key harus disimpan dengan algoritma AES agar bisa digunakan untuk kriptografi dua arah. Ini lebih aman karena data di tokennya ga bisa dibaca. Kekurangannya ini lebih lambat daripada JWS + Secret Key karena algoritmanya kompleks. Ini cocoknya untuk token yang menyimpan payload sensitif dan tempat Authentication & Authorization di server yang sama. Ini ibarat kita dikasih gembok langsung oleh Presiden dan hanya Presiden yang bisa membukanya.
public static String generateJweToken(String username, SecretKey secretKey) {
Map<String, String> claims = Maps.of("role", "superuser").and("realName", "ferry").build();
return Jwts.builder()
.subject(username)
.claims(claims)
.expiration(new Date(System.currentTimeMillis() + (30 * 60 * 1000)))
.encryptWith(secretKey, Jwts.KEY.A256GCMKW, Jwts.ENC.A256GCM)
.compact();
}
public static Jwe<Claims> parseJweToken(String token, SecretKey secretKey) {
if(token == null || token.isBlank()){
return null;
}
try {
return Jwts.parser()
.decryptWith(secretKey)
.build()
.parseEncryptedClaims(token);
} catch (Exception e) {
return null;
}
}
private static void jwePublic(){
String secretKeyStr = getSecretKeyStr();
SecretKey secretKey = new SecretKeySpec(Base64.getDecoder().decode(secretKeyStr), "AES");
String jwtToken = generateJweToken("ferry", secretKey);
System.out.println("jwtToken = " + jwtToken);
Jwe<Claims> claims = parseJweToken(jwtToken, secretKey);
System.out.println("claims = " + claims);
}Cara penggunaannya sama kayak JWS + Secret Key. Bedanya hanya di alogritmanya saja. Untuk algoritma enkripsi kita menggunakan A256GCM. Untuk algoritma Key menggunakan A256GCMKW.
JWE + Public/Private Key
Ini kombinasi yang paling aman karena datanya dienkripsi dan key untuk Authentication & Authorization berbeda. Kekurangannya, ini yang paling lambat karena algoritmanya paling kompleks. Ini cocok untuk aplikasi yang terintegrasi dengan sistem eksternal dan datanya sensitif sehingga risikonya harus sangat minimal. Public Key kita bagikan ke sistem eksternal yang mengirim pesan, sedangkan Private Key kita simpan di sistem internal untuk membaca isi pesan. Ini ibarat kita dikasih gembok milik Presiden lewat orang-orang kepercayaannya, tapi hanya Presiden yang punya kunci untuk membukanya.
public static String generateJweToken(String username, PublicKey publicKey) {
Map<String, String> claims = Maps.of("role", "superuser").and("realName", "ferry").build();
return Jwts.builder()
.subject(username)
.claims(claims)
.expiration(new Date(System.currentTimeMillis() + (30 * 60 * 1000)))
.encryptWith(publicKey, Jwts.KEY.ECDH_ES_A256KW, Jwts.ENC.A256GCM)
.compact();
}
public static Jwe<Claims> parseJweToken(String token, PrivateKey privateKey) {
if(token == null || token.isBlank()){
return null;
}
try {
return Jwts.parser()
.decryptWith(privateKey)
.build()
.parseEncryptedClaims(token);
} catch (Exception e) {
return null;
}
}
private static void jwePrivate() throws NoSuchAlgorithmException, InvalidKeySpecException{
KeyFactory keyFactory = KeyFactory.getInstance("EC");
String publicKeyStr = getPublicKeyStr();
PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyStr)));
String privateKeyStr = getPrivateKeyStr();
PrivateKey privateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyStr)));
String jwtToken = generateJweToken("ferry", publicKey);
System.out.println("jwtToken = " + jwtToken);
Jwe<Claims> claims = parseJweToken(jwtToken, privateKey);
System.out.println("claims = " + claims);
}Berbeda dengan JWS + Public/Private Key, di sini kita membuat token menggunakan Public Key. Sedangkan Private Key digunakan untuk verifikasi. Jadi, token tersebut hanya bisa diverifikasi oleh server yang dituju doang. Kita menggunakan algoritma key ECDH_ES_A256KW dan algoritma enkripsi A256GCM.
Verdict
JWT ini algoritmanya fleksibel dan bisa dipilih sesuka hati. Saking fleksibelnya, kita harus hati-hati dalam menentukan algoritma yang dipilih. Ini salah satu kritikan orang-orang terkait JWT karena bisa saja algoritma yang diimplementasi lemah. Alternatifnya ada PASETO dengan implementasi yang lebih disiplin. Selama implementasi JWT yang kita lakukan sudah sesuai best practice menurut gw JWT masih worth it. JWT menawarkan beberapa pilihan seperti JWS + Secret Key, JWS + Public/Private Key, JWE + Secret Key, dan JWE + Public/Private Key. JWS + Secret Key adalah pilihan yang paling simple dan performanya paling cepat. Semakin strict pilihannya semakin aman, tapi secara performa akan makin lambat.
