Tampilan
Kenapa penting
Setelah pengguna login dengan password, API perlu cara mengenali pengguna itu di request berikutnya tanpa meminta password lagi. Di aplikasi web klasik, jawabannya session + cookie. Di API yang dipanggil aplikasi mobile, SPA, atau layanan lain, jawabannya hampir selalu token, dan format token paling umum adalah JWT (JSON Web Token). Kamu akan menemukannya di hampir setiap lowongan backend: "pengalaman dengan JWT/OAuth2". Masalahnya, JWT juga sumber banyak celah keamanan — token yang tidak divalidasi aud-nya, secret lemah, token disimpan sembarangan, atau berlaku seminggu tanpa bisa dicabut. Bab ini membahas cara kerja JWT, cara Spring Security memvalidasinya, dan batasan yang harus kamu pahami sebelum memakainya.
Konsep inti
Struktur JWT: tiga bagian dipisah titik
JWT adalah string header.payload.signature. Header dan payload berupa JSON yang di-encode Base64URL (Base64 yang aman untuk URL, tanpa padding =). Signature adalah hasil tanda tangan kriptografis atas header.payload.
| Bagian | Isi | Contoh setelah di-decode |
|---|---|---|
| Header | algoritma dan tipe token | {"alg":"HS256","typ":"JWT"} |
| Payload | claim — pernyataan tentang subjek | {"sub":"user-42","scope":"orders:read",...} |
| Signature | bukti header + payload tidak diubah | byte acak hasil HMAC/RSA/ECDSA |
Program Java murni berikut membuat JWT HS256 dengan javax.crypto.Mac, membaca payload-nya tanpa kunci, lalu menunjukkan bahwa payload yang diubah membuat signature tidak cocok:
java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class JwtAnatomy {
static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
static String b64(String s) {
return B64.encodeToString(s.getBytes(StandardCharsets.UTF_8));
}
static String hs256(String data, byte[] key) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return B64.encodeToString(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
}
public static void main(String[] args) throws Exception {
byte[] key = "rahasia-demo-minimal-32-byte-panjang!".getBytes(StandardCharsets.UTF_8);
String header = b64("{\"alg\":\"HS256\",\"typ\":\"JWT\"}");
String payload = b64("{\"sub\":\"user-42\",\"scope\":\"orders:read\",\"exp\":1798761600}");
String token = header + "." + payload + "." + hs256(header + "." + payload, key);
System.out.println(token);
// Siapa pun bisa membaca payload tanpa kunci
String[] parts = token.split("\\.");
System.out.println(new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8));
// Penyerang mengubah scope -> signature lama tidak cocok lagi
String tampered = b64("{\"sub\":\"user-42\",\"scope\":\"orders:write\",\"exp\":1798761600}");
String expected = hs256(parts[0] + "." + tampered, key);
boolean valid = MessageDigest.isEqual(
expected.getBytes(StandardCharsets.US_ASCII),
parts[2].getBytes(StandardCharsets.US_ASCII));
System.out.println("Token diubah valid? " + valid);
}
}Output:
text
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQyIiwic2NvcGUiOiJvcmRlcnM6cmVhZCIsImV4cCI6MTc5ODc2MTYwMH0.kY_NQys6Of70JQ7xPoCKlt7078jpVFnTdVkqx8G1bCQ
{"sub":"user-42","scope":"orders:read","exp":1798761600}
Token diubah valid? falseDua pelajaran penting. Pertama, payload bisa dibaca siapa saja — JWT bertanda tangan menjamin integritas, bukan kerahasiaan. Jangan pernah menaruh password, nomor kartu, atau data pribadi di sana. Kedua, perbandingan signature memakai MessageDigest.isEqual yang waktunya konstan, bukan String.equals yang bisa membocorkan informasi lewat selisih waktu. Di aplikasi nyata kamu tidak menulis kode ini sendiri — library (Nimbus, dipakai Spring Security) yang mengerjakannya. Contoh ini hanya untuk membongkar "kotak hitam"-nya.
Claim standar
RFC 7519 mendefinisikan beberapa registered claim. Semuanya opsional di spesifikasi, tetapi API yang aman memakainya:
| Claim | Arti | Yang harus dicek API |
|---|---|---|
iss | issuer — siapa yang menerbitkan | sama persis dengan auth server yang dipercaya |
sub | subject — ID pengguna/klien | dipakai sebagai identitas, bukan email |
aud | audience — untuk API mana token ditujukan | memuat ID API kamu |
exp | expiration (detik sejak epoch) | belum lewat (dengan toleransi jam kecil) |
nbf | not before | sudah lewat |
iat | issued at | informasi waktu terbit |
jti | JWT ID unik | berguna untuk denylist/pencabutan |
Selain itu ada claim kustom seperti scope atau roles. Ingat: claim hanya bisa dipercaya setelah signature dan claim standar lolos validasi.
HS256 vs RS256/ES256
- HS256 (HMAC-SHA256) — simetris. Kunci yang sama dipakai untuk menandatangani dan memverifikasi. Sederhana, cocok bila satu aplikasi menerbitkan sekaligus memvalidasi token. Secret minimal 256 bit acak, bukan kata sandi yang bisa ditebak.
- RS256 (RSA) dan ES256 (ECDSA P-256) — asimetris. Auth server menandatangani dengan private key; API memverifikasi dengan public key yang biasanya dipublikasikan di endpoint JWKS (
/.well-known/jwks.json). API tidak bisa memalsukan token walau servernya dibobol. ES256 menghasilkan signature lebih kecil daripada RS256.
Aturan praktis: begitu ada lebih dari satu layanan yang memvalidasi token, atau token diterbitkan pihak lain (Keycloak, Auth0, Google), pakai algoritma asimetris. Validator juga harus mengunci algoritma yang diterima — jangan pernah menerima alg: none atau membiarkan header token memilih algoritma sesukanya.
Validasi wajib di resource server
Resource server adalah API yang menerima token dan melindungi data. Untuk setiap request, ia harus memeriksa:
- Signature valid dengan kunci/algoritma yang diharapkan.
expbelum lewat dannbfsudah lewat.isssama dengan issuer yang dipercaya.audmemuat ID API ini — tanpa cek ini, token yang diterbitkan untuk aplikasi lain dari auth server yang sama bisa dipakai untuk mengakses API-mu.
Spring Security menyediakan semua ini lewat modul OAuth2 Resource Server. Tambahkan starter-nya (nama starter di Spring Boot 4):
xml
<!-- Potongan pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>Konfigurasi paling sederhana memakai public key dari auth server:
yaml
# Potongan application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://auth.tokoku.id/.well-known/jwks.json
audiences: orders-apiLalu aktifkan di SecurityFilterChain:
java
// Potongan — Spring Security 7
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable()) // aman karena token dikirim lewat header, bukan cookie
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
return http.build();
}
}Filter BearerTokenAuthenticationFilter membaca header Authorization: Bearer <token>, JwtDecoder memverifikasi signature dan exp/nbf, lalu hasilnya menjadi JwtAuthenticationToken di SecurityContext. Token tidak valid → 401 Unauthorized dengan header WWW-Authenticate: Bearer error="invalid_token". Secara default, claim scope diubah menjadi authority berawalan SCOPE_, misalnya SCOPE_orders:read.
Bila kamu perlu kontrol penuh — misalnya API ini sendiri menerbitkan token HS256 — definisikan JwtDecoder sendiri dengan validator eksplisit:
java
// Potongan — decoder HS256 dengan validasi iss dan aud
@Bean
JwtDecoder jwtDecoder(@Value("${app.jwt.secret}") String secret) {
SecretKey key = new SecretKeySpec(Base64.getDecoder().decode(secret), "HmacSHA256");
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key)
.macAlgorithm(MacAlgorithm.HS256)
.build();
OAuth2TokenValidator<Jwt> audience = new JwtClaimValidator<List<String>>(
JwtClaimNames.AUD, aud -> aud != null && aud.contains("orders-api"));
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer("https://api.tokoku.id"), audience));
return decoder;
}JwtValidators.createDefaultWithIssuer sudah mencakup cek exp/nbf (dengan toleransi jam 60 detik) dan iss. Secret dibaca dari konfigurasi/environment, bukan ditulis di kode.
Access token pendek + refresh token
Karena JWT sulit dicabut (lihat bagian berikutnya), pola umumnya adalah dua token:
| Token | Umur | Format | Dikirim ke |
|---|---|---|---|
| Access token | 5–15 menit | JWT, divalidasi stateless | setiap request API |
| Refresh token | jam–hari | string acak, disimpan di DB (hash) | hanya endpoint /auth/refresh |
Saat access token kedaluwarsa, klien menukar refresh token dengan access token baru. Refresh token disimpan di server sehingga bisa dicabut (logout, ganti password, perangkat hilang). Terapkan rotation: setiap refresh menerbitkan refresh token baru dan mematikan yang lama; bila refresh token lama dipakai lagi, anggap ada pencurian dan cabut seluruh keluarganya.
Menyimpan token di klien
- Aplikasi mobile: simpan di penyimpanan aman OS (Keychain/Keystore).
- SPA di browser: hindari
localStorage/sessionStorage— skrip XSS bisa membacanya. Simpan access token di memori (variabel JavaScript) dan refresh token di cookieHttpOnly; Secure; SameSite=Strictyang hanya dikirim ke path refresh. Pilihan yang lebih aman lagi adalah pola BFF (Backend for Frontend): browser hanya memegang cookie session ke BFF, dan BFF yang menyimpan token. - Server-ke-server: token disimpan di memori proses dan diambil ulang sebelum kedaluwarsa.
Kelemahan JWT dan kapan session lebih baik
Validasi JWT stateless: server tidak mencatat token yang sudah terbit, sehingga logout tidak benar-benar mematikan access token. Pilihan mitigasinya: umur pendek, denylist jti di Redis (mengorbankan sebagian sifat stateless), atau token opaque yang dicek ke auth server lewat introspection. Masalah lain: payload membengkak bila diisi banyak role, perubahan role baru berlaku setelah token diperbarui, dan kesalahan konfigurasi validator langsung berarti celah.
Untuk aplikasi web monolit dengan satu domain — browser bicara langsung ke satu backend — session biasa (cookie HttpOnly + session di server atau Spring Session/Redis) sering lebih sederhana dan lebih aman: logout instan, tidak ada token di JavaScript, dan perlindungan CSRF sudah tersedia. JWT bersinar saat ada banyak klien (mobile, partner, layanan lain) atau banyak resource server yang perlu memvalidasi identitas tanpa memanggil database.
Contoh di dunia backend
Layanan pesanan e-commerce menerima token dari auth server internal. Controller membaca identitas dari token, bukan dari body request — klien tidak boleh menentukan sendiri siapa dirinya:
java
// Potongan — controller resource server
@RestController
@RequestMapping("/api/orders")
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping
List<OrderSummary> myOrders(@AuthenticationPrincipal Jwt jwt) {
// sub = ID pelanggan dari auth server, sudah lolos verifikasi signature, exp, iss, aud
return orderService.findByCustomer(jwt.getSubject());
}
@PostMapping
ResponseEntity<OrderSummary> place(@AuthenticationPrincipal Jwt jwt,
@Valid @RequestBody PlaceOrderRequest request) {
OrderSummary created = orderService.place(jwt.getSubject(), request);
return ResponseEntity.created(URI.create("/api/orders/" + created.id())).body(created);
}
}Request dan respons saat token kedaluwarsa:
bash
curl -i https://api.tokoku.id/api/orders -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."
# HTTP/1.1 401
# WWW-Authenticate: Bearer error="invalid_token", error_description="...Jwt expired at ...", ... (dipersingkat)Klien kemudian memanggil /auth/refresh dengan refresh token di cookie, mendapat access token baru, lalu mengulang request. Detail pemetaan scope/role ke aturan akses dibahas di bab otorisasi.
Pertanyaan interview
1. Apakah JWT aman untuk menyimpan data sensitif?
Tidak. JWT bertanda tangan (JWS) hanya di-encode Base64URL, sehingga siapa pun yang memegang token bisa membaca payload-nya. Signature hanya menjamin isi tidak diubah. Bila isi harus rahasia, gunakan JWE (JWT terenkripsi) atau lebih baik jangan menaruh data itu di token sama sekali — cukup ID, lalu ambil detailnya dari server.
2. Apa beda HS256 dan RS256, dan kapan memakai masing-masing?
HS256 simetris: satu secret untuk menandatangani dan memverifikasi, cocok bila penerbit dan validator adalah aplikasi yang sama. RS256/ES256 asimetris: auth server menandatangani dengan private key, API memverifikasi dengan public key dari JWKS. Untuk banyak layanan atau auth server pihak ketiga, pakai asimetris agar layanan yang bocor tidak bisa memalsukan token.
3. Bagaimana cara "logout" bila memakai JWT?
Access token tidak bisa dicabut secara stateless, jadi buat umurnya pendek. Logout mencabut refresh token di database sehingga tidak bisa diperpanjang. Bila perlu pencabutan seketika, simpan jti token yang dicabut di denylist (misalnya Redis) sampai exp-nya lewat, atau pakai token opaque dengan introspection.
Jebakan umum
- Tidak memvalidasi
aud— token untuk aplikasi lain dari auth server yang sama ikut diterima API-mu. - Secret HS256 lemah seperti
secretataujwt-key— bisa di-brute force offline dari satu token saja. Pakai minimal 256 bit acak dari secret manager. - Access token berumur panjang (hari/minggu) tanpa refresh token — token curian berlaku lama dan tidak bisa dicabut.
- Menaruh data pribadi di payload karena mengira JWT terenkripsi.
- Menyimpan token di
localStoragepada SPA, sehingga satu celah XSS cukup untuk mencuri sesi. - Menulis parser/validator JWT sendiri atau membiarkan header
algmenentukan algoritma — pakai library yang matang dan kunci algoritmanya.
Ringkasan
- JWT =
header.payload.signature; payload hanya di-encode, jadi bisa dibaca — signature menjamin integritas. - Validasi wajib: signature,
exp/nbf,iss, danaud; claim kustom baru dipercaya setelah semua lolos. - HS256 untuk satu aplikasi; RS256/ES256 + JWKS saat banyak layanan atau token dari auth server.
- Spring Security:
oauth2ResourceServer(o -> o.jwt(...))+jwk-set-uri/audiences, atauJwtDecoderkustom denganJwtValidatorsdanJwtClaimValidator. - Access token pendek + refresh token yang disimpan dan bisa dicabut di server, dengan rotation.
- JWT sulit dicabut; untuk web monolit satu domain, session biasa sering lebih sederhana dan aman.