JWT (JSON Web Token) untuk Autentikasi

13 min readFundamental
JWTSecurityAutentikasiBackend

Di artikel HTTP & Siklus Request-Response kita melihat bahwa setiap request datang dengan header Authorization: Bearer <token>. Sekarang pertanyaannya: apa isi token itu, dan kenapa server begitu percaya padanya? Jawabannya ada di JWT — salah satu format token paling populer untuk autentikasi API di aplikasi web dan mobile.

JWT (JSON Web Token) adalah compact token yang membawa identitas pengguna di dalam dirinya sendiri, dan didefinisikan secara resmi di RFC 7519. Karena informasinya dibawa sendiri oleh token, server tidak perlu menyimpan state login di memory — inilah yang membuat JWT disebut stateless. Mari kita bedah dari nol.

Apa itu JWT

Bayangkan JWT sebagai tiket masuk konser: tiket itu berisi nama Anda, tanggal acara, dan nomor kursi, ditandatangani oleh panitia dengan segel khusus. Ketika Anda menunjukkannya ke penjaga pintu, penjaga cukup mengecek segelnya — tidak perlu menelepon panitia untuk memastikan Anda benar-benar sudah membayar.

Secara teknis, JWT adalah string terenkode (bukan terenkripsi) yang berisi klaim dalam format JSON. Klaim (claims) adalah pernyataan terstruktur tentang pengguna, misalnya "saya adalah user id 42" atau "role saya ADMIN". Token ini ditandatangani dengan kunci yang hanya diketahui server, sehingga isinya tidak bisa diubah oleh pihak lain tanpa merusak tanda tangan.

Struktur JWT selalu terdiri dari tiga bagian yang dipisahkan titik:

xxxxx.yyyyy.zzzzz
───── ───── ─────
header payload signature

Kenapa backend memakai JWT

Sistem autentikasi berbasis session klasik bekerja seperti ini: server membuat session id, menyimpannya di memory/database, lalu memberikannya ke klien dalam bentuk cookie. Masalah muncul ketika aplikasi Anda tumbuh besar:

SituasiSession klasikJWT
Tiga server backend berbagi bebanSession harus disinkronkan (atau pakai Redis/sticky session)Tidak perlu, token berdiri sendiri
Aplikasi mobile & desktopCookie lebih rumit di-handle lintas platformToken dikirim manual via header, mudah
Satu backend dipakai banyak klien (web, mobile, IoT)Tiap klien butuh mekanisme session sendiriMekanisme identik untuk semua klien
Skala horizontal (server ditambah)Butuh infra penyimpanan session ekstraCukup bagikan secret yang sama
Logout instanMudah, cukup hapus session di serverSulit (perlu blacklist, dibahas nanti)

Ingat kuncinya: session menyimpan "siapa saya" di server. JWT menyimpan "siapa saya" di dalam token yang dibawa klien, dan server cukup mengecek tanda tangannya. Tidak ada yang lebih baik secara mutlak — keduanya punya trade-off yang akan kita bahas lebih dalam di Session vs JWT.

Perbandingan singkat session vs JWT

AspekSession-basedJWT
Lokasi dataDi server (memory/DB/Redis)Di token, dibawa klien
State di serverAda (serverful)Tidak ada (stateless)
Skala horizontalButuh session store bersamaCukup secret/keyset yang sama
Revocation / logoutLangsung efektifButuh blacklist atau token kedaluwarsa
MuatanHanya session id (kecil)Seluruh klaim ikut (besar)

Tiga Bagian JWT

Setiap JWT dibangun dari tiga bagian. Diagram berikut merangkumnya sekaligus:

Struktur JWT

BagianIsiPeran
HeaderAlgoritma + tipe tokenMemberi tahu server cara memverifikasi
PayloadKlaim (identitas & metadata)Membawa data pengguna
SignatureHasil hash dari header + payload dengan kunciMenjamin token tidak dipalsukan

Header

Header adalah objek JSON yang mendeskripsikan bagaimana token ditandatangani. Dua field yang wajib dipahami: alg (algoritma) dan typ (tipe token).

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

Header ini kemudian di-encode dengan Base64URL — tanpa karakter =, +, dan / supaya aman di URL. Hasilnya adalah potongan pertama token:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

Payload

Payload berisi klaim. Ada tiga kategori klaim menurut RFC 7519:

  • Registered claims — klaim standar yang sudah punya makna baku.
  • Public claims — klaim yang terdaftar di IANA agar tidak bertabrakan namanya.
  • Private claims — klaim buatan aplikasi Anda sendiri.

Klaim standar yang paling sering dipakai:

KlaimKepanjanganArti
subsubjectIdentitas utama token — biasanya user id
iatissued atKapan token diterbitkan (epoch seconds)
expexpiration timeKapan token kedaluwarsa — wajib dicek server
ississuerSiapa yang menerbitkan token (mis. https://auth.app.com)
audaudienceUntuk siapa token ini — biasanya nama resource/API
{
  "sub": "42",
  "name": "Hendro",
  "role": "ADMIN",
  "iat": 1754342400,
  "exp": 1754428800,
  "iss": "https://api.endsite.dev"
}

Di contoh di atas, selain klaim standar ada klaim kustom name dan role. Klaim kustom inilah yang biasa dipakai aplikasi untuk autentikasi otorisasi tanpa memanggil database di setiap request. Bagian payload yang di-encode adalah potongan kedua token:

eyJzdWIiOiI0MiIsIm5hbWUiOiJIZW5kcm8iLCJyb2xlIjoiQURNSU4iLCJpYXQiOjE3NTQzNDI0MDAsImV4cCI6MTc1NDQyODgwMCwiaXNzIjoiaHR0cHM6Ly9hcGkuZW5kc2l0ZS5kZXYifQ

Signature

Signature adalah bagian yang membuat JWT aman. Rumusnya sederhana tapi kuat:

signature = HMAC-SHA256(
    base64url(header) + "." + base64url(payload),
    secret_key
)

Garis besar prosesnya:

  1. Gabungkan base64url(header) dan base64url(payload) dengan tanda titik.
  2. Hash gabungan itu bersama kunci rahasia dengan algoritma yang tertulis di alg.
  3. Hasil hash di-encode Base64URL menjadi potongan ketiga token.

Kenapa bagian ini membuat token tidak bisa dipalsukan? Karena untuk menghitung signature yang benar, penyerang harus tahu kunci rahasia. Kalau penyerang mengubah role dari USER menjadi ADMIN di payload, signature otomatis tidak cocok lagi — dan verifikasi akan gagal. Anda bisa membuktikannya langsung: tempel token asli di bawah ke jwt.io debugger, ubah satu huruf di payload, dan lihat tulisan Invalid Signature muncul.

Contoh JWT lengkap

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiI0MiIsIm5hbWUiOiJIZW5kcm8iLCJyb2xlIjoiQURNSU4iLCJpYXQiOjE3NTQzNDI0MDAsImV4cCI6MTc1NDQyODgwMCwiaXNzIjoiaHR0cHM6Ly9hcGkuZW5kc2l0ZS5kZXYifQ.
4rKx0...8zLQ

Salin ketiga baris itu (gabung jadi satu baris tanpa baris baru), tempel di bagian Encoded di jwt.io, lalu masukkan secret apa pun. Anda akan melihat header dan payload ter-decoding dengan mudah — bukti bahwa isi JWT bukan rahasia, siapa pun bisa membacanya. Yang tidak bisa siapa pun lakukan tanpa kunci adalah membuat signature yang valid.

Poin penting yang sering disalahpahami: JWT di-encode dengan Base64URL, bukan dienkripsi. Payload bisa dibaca siapa saja yang memegang token. Jangan pernah menaruh password, nomor kartu, atau data sensitif lain di dalam payload.

Alur Login dan Verifikasi

Sekarang kita rangkai semuanya ke dalam alur nyata di aplikasi:

  Klien (Frontend/Mobile)                    Server Backend
         |                                          |
         |  1. POST /api/auth/login                |
         |     {email, password}                    |
         |----------------------------------------->|
         |                                 2. Validasi kredensial
         |                                 3. Buat & tanda tangani JWT
         |  4. 200 OK {accessToken}                |
         |<-----------------------------------------|
         |                                          |
         |  5. Simpan token (memory/cookie)         |
         |                                          |
         |  6. GET /api/users                        |
         |     Authorization: Bearer <token>        |
         |----------------------------------------->|
         |                                 7. Verifikasi signature
         |                                 8. Cek exp (belum kedaluwarsa?)
         |                                 9. Proses request
         |  10. 200 OK {data}                      |
         |<-----------------------------------------|

Tahapannya satu per satu:

  1. Login — klien mengirim kredensial (email + password) ke endpoint /api/auth/login.
  2. Validasi kredensial — server mengecek ke database. Jangan pernah menyimpan password polos; bandingkan dengan hash, misalnya pakai BCrypt.
  3. Terbitkan token — server membuat JWT berisi sub, iat, exp, dan klaim kustom, lalu menandatanganinya.
  4. Kirim ke klien — token dikembalikan lewat body response atau httpOnly cookie.
  5. Klien menyimpan token — untuk dipakai di setiap request berikutnya.
  6. Setiap request — klien mengirim header Authorization: Bearer <token>.
  7. Server verifikasi signature — jika signature tidak cocok, token dianggap palsu dan request ditolak dengan 401 Unauthorized.
  8. Server cek exp — jika sudah lewat waktu kedaluwarsa, token ditolak.
  9. Proses request — jika valid, klaim di payload (misal role) bisa langsung dipakai untuk autentikasi vs otorisasi.
  10. Response — data dikembalikan ke klien.
// Skema yang dilakukan server di setiap request yang butuh autentikasi
public boolean isValidToken(String token) {
    try {
        Jws<Claims> jws = Jwts.parser()
                .verifyWith(SECRET_KEY)
                .build()
                .parseSignedClaims(token);
        return jws.getPayload().getExpiration().after(new Date());
    } catch (JwtException | IllegalArgumentException e) {
        return false;
    }
}

HS256 vs RS256

Dua algoritma penandatanganan yang paling umum, keduanya disebutkan di jwt.io introduction. Perbedaan intinya adalah simetris vs asimetris:

AspekHS256 (HMAC)RS256 (RSA)
Jenis kunciSimetris — satu secretAsimetris — pasangan private/public key
PenandatangananSatu secret yang samaPrivate key (dirahasiakan)
VerifikasiSecret yang samaPublic key (boleh disebar)
Siapa bisa memverifikasiHanya yang tahu secretSiapa pun yang punya public key
Cocok untukAplikasi Anda sendiri, satu servicePihak ketiga / banyak service penerima
Contoh pemakaianBackend internal, monolithOkta, Auth0, Keycloak, microservices

Aturan praktis: kalau token diterbitkan dan diverifikasi oleh satu server milik Anda sendiri, HS256 dengan secret yang kuat sudah cukup. Kalau token diterbitkan oleh pihak ketiga (misalnya Okta atau Auth0) dan banyak service Anda yang harus memverifikasinya, pakai RS256 — service cukup memegang public key, tidak perlu berbagi rahasia. Spring Security mendukung keduanya lewat JWT resource server.

Klaim exp & iat dan Refresh Token

Dua klaim ini adalah pasangan yang wajib dipahami bersama:

  • iat (issued at) — mencatat kapan token dibuat. Berguna untuk audit dan untuk menolak token yang terlalu tua.
  • exp (expiration time) — mencatat kapan token tidak berlaku lagi. Server wajib mengecek ini.

Kenapa token harus kedaluwarsa? Karena sekali token bocor, penyerang bisa memakainya seperti pengguna asli sampai token itu tidak berlaku. Waktu kedaluwarsa yang pendek (misal 15–30 menit untuk access token) memperkecil "jendela bahaya". Namun token yang sering kedaluwarsa juga membuat pengguna harus login terus-menerus — solusinya adalah refresh token:

  1. Saat login, server menerbitkan dua token: access token (berumur pendek) dan refresh token (berumur panjang, misal 7–30 hari).
  2. Ketika access token kedaluwarsa, klien mengirim refresh token ke endpoint khusus untuk mendapatkan access token baru.
  3. Refresh token sebaiknya disimpan dengan aman dan bisa dicabut (misal disimpan di database sehingga bisa di-blacklist saat logout).

Analoginya: access token itu seperti "tiket masuk hari ini" — cepat kedaluwarsa supaya aman. Refresh token itu seperti "KTP" — jarang dipakai, tapi dipakai untuk memperbarui tiket ketika masuk ke tempat Anda bekerja setiap hari.

Implementasi di Spring Security

Mari kita praktikkan. Di Spring Security, verifikasi JWT adalah fitur built-in melalui JwtDecoder. Anda tidak perlu menulis verifikasi manual — cukup daftarkan decoder.

1. Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

2. Konfigurasi SecurityFilterChain

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(Customizer.withDefaults())
            );
        return http.build();
    }

    @Bean
    JwtDecoder jwtDecoder() {
        SecretKey key = Keys.hmacShaKeyFor("rahasia-super-kuat-yang-panjang".getBytes());
        return NimbusJwtDecoder.withSecretKey(key).build();
    }
}

Dengan konfigurasi ini, Spring Security otomatis membaca header Authorization: Bearer <token>, memverifikasi signature dan exp, lalu mengisi Authentication di dalam request. Endpoint yang butuh ROLE_ADMIN otomatis menolak pengguna lain.

3. Mengambil klaim di controller

@RestController
public class UserController {

    @GetMapping("/api/users/me")
    public Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
        return Map.of(
            "id", jwt.getSubject(),
            "email", jwt.getClaimAsString("email"),
            "role", jwt.getClaimAsString("role")
        );
    }
}

4. Generate token dengan JwtService

@Service
public class JwtService {

    private final SecretKey key = Keys.hmacShaKeyFor("rahasia-super-kuat-yang-panjang".getBytes());
    private static final long EXPIRATION_MS = 30 * 60 * 1000; // 30 menit

    public String generateToken(UserDetails user) {
        Instant now = Instant.now();
        return Jwts.builder()
                .subject(user.getUsername())
                .claim("role", user.getAuthorities())
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plusMillis(EXPIRATION_MS)))
                .issuer("https://api.endsite.dev")
                .signWith(key)
                .compact();
    }
}

Jwts.builder() berasal dari library jjwt — yang sama dengan yang dipakai contoh isValidToken di atas. Perhatikan issuer yang konsisten dengan nilai iss yang kita verifikasi di decoder.

Risiko & Best Practice

JWT praktis, tapi ada beberapa jebakan keamanan yang wajib Anda kenali.

Di mana menyimpan token?

Ini perdebatan klasik:

PenyimpananKelebihanRisiko
localStorageSimpel, mudah dibaca JavaScriptRentan XSS — script jahat bisa mencuri token
httpOnly cookieTidak bisa dibaca JavaScript, tahan XSSRentan CSRF — perlu proteksi token CSRF tambahan

Intinya: token yang disimpan di localStorage hanya aman jika aplikasi Anda benar-benar bebas XSS (yang sulit dijamin 100%). Banyak praktisi menyarankan httpOnly cookie untuk web, sementara aplikasi mobile biasanya menyimpan token di penyimpanan aman bawaan OS (Keychain/Keystore).

Jangan taruh data sensitif di payload

Sudah disebutkan beberapa kali karena ini penting: payload JWT hanya di-base64, bukan dienkripsi. Siapa pun yang memegang token bisa membacanya. Jadi:

  • Boleh: sub, email, role, name.
  • Jangan pernah: password, nomor kartu kredit, nomor KTP, token internal.

Selalu verifikasi di server

  • Verifikasi signature dengan kunci yang tepat.
  • Cek exp (dan iat, iss, aud kalau relevan).
  • Jangan pernah percaya payload tanpa verifikasi — attacker bisa membuat token sendiri kalau Anda hanya mem-parsing base64 tanpa mengecek tanda tangan.

Penanganan logout

Karena JWT stateless, server tidak otomatis tahu token sudah "dicabut". Dua pendekatan umum:

  • Blacklist/denylist — simpan jti (JWT ID) dari token yang dicabut ke Redis sampai token itu kedaluwarsa secara alami; verifikasi menolak token yang ada di daftar.
  • Rotasi refresh token — saat refresh, token lama diganti yang baru; token yang dipakai ulang dianggap bocor dan seluruh sesi dicabut.

Kesalahan Umum

Ringkasan jebakan yang paling sering ditemui di lapangan:

KesalahanKenapa berbahayaSolusi
Menaruh password di payloadPayload bisa dibaca siapa punHanya kirim sub/email/role
Tidak mengecek expToken bekas bisa dipakai selamanyaWajib cek exp di decoder
Memakai token tanpa verifikasi signatureAttacker bisa memalsukan role jadi ADMINSelalu verifikasi sebelum percaya klaim
Secret terlalu pendek/sederhanaBrute-force HMAC jadi mudahGunakan secret ≥ 256 bit acak
Menyimpan token di localStorage tanpa mitigasi XSSToken bisa dicuri script jahatPertimbangkan httpOnly cookie + CSRF
HS256 untuk verifikasi publikSecret ikut bocor jika public key dibagikanPakai RS256 untuk verifikasi lintas service
// Contoh anti-pattern: membaca klaim TANPA verifikasi signature
Claims claims = Jwts.parser()          // salah: tanpa key, tanpa verifyWith
        .build()
        .parseClaimsJwt(token)          // parseClaimsJwt TIDAK memverifikasi signature
        .getPayload();

String role = claims.get("role", String.class); // bisa saja palsu

Baris kode di atas terlihat polos, tapi parseClaimsJwt hanya mendecode, tidak memverifikasi. Attacker bisa mengganti role menjadi ADMIN, meng-encode ulang dengan secret apa pun, dan server akan percaya. Selalu gunakan Jwts.parser().verifyWith(key)... seperti di bagian alur verifikasi.

Ringkasan

JWT adalah token stateless berisi header, payload, dan signature. Header menentukan algoritma, payload membawa klaim identitas, dan signature menjamin token tidak bisa dipalsukan. Server memverifikasi signature dan exp di setiap request tanpa perlu menyimpan session — inilah yang membuat JWT cocok untuk API yang melayani banyak klien dan server yang ber-skala horizontal. Pilih HS256 untuk aplikasi mandiri dan RS256 saat melibatkan pihak ketiga. Ingat tiga peringatan besarnya: payload bukanlah enkripsi, verifikasi adalah harga mati, dan token harus selalu punya masa kedaluwarsa.

Lanjut membaca

← Back to technical articles