Jebakan Keamanan JWT dan Sesi, serta Cara Menghindarinya
Kesalahan JWT dan sesi yang berujung pengambilalihan akun: algorithm confusion, secret lemah, cek klaim yang hilang, penyimpanan token, rotasi, dan logout.
· 7 menit baca · Lina Source LLC
JSON Web Token adalah format yang masuk akal dengan daftar panjang sisi tajamnya. Token itu sendiri jarang menjadi masalah. Bug-nya hidup pada cara ia diverifikasi, di mana ia disimpan, berapa lama ia berlaku, dan apa yang terjadi ketika pengguna keluar. Setiap kesalahan itu punya hasil akhir yang sama: seseorang memegang token yang tidak semestinya, dan server Anda menerimanya.
Dua kelemahan yang paling sering muncul adalah CWE-347, verifikasi tanda tangan kriptografis yang tidak tepat, dan CWE-613, kedaluwarsa sesi yang tidak memadai. Contoh di bawah ini memakai jose, pustaka JavaScript yang banyak dipakai untuk JWT dan berjalan di Node.js, runtime edge, serta browser.
Decoding bukan verifikasi
Bentuk CWE-347 yang paling langsung adalah membaca klaim dari sebuah token tanpa memeriksa tanda tangannya. Setiap pustaka JWT punya fungsi decode untuk keperluan debugging, dan fungsi itu muncul di middleware autentikasi lebih sering daripada yang seharusnya. Token yang di-decode hanyalah base64 yang bisa ditulis siapa saja. Di kode JavaScript, polanya sering bersembunyi di sebuah helper kecil yang memecah token pada tanda titik lalu menjalankan JSON.parse pada bagian tengahnya. Ia lolos di setiap tes, karena token pengujiannya valid, dan ia menerima setiap token palsu di produksi.
import { decodeJwt, jwtVerify } from 'jose';
// Rentan: siapa pun bisa mencetak token dengan sub berisi ID pengguna mana pun
const claims = decodeJwt(token);
const userId = claims.sub;
// Benar: tanda tangan, algoritma, dan klaim diperiksa lebih dulu
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
const verifiedUserId = payload.sub;alg none dan algorithm confusion
Header JWT menyebutkan algoritma mana yang menandatangani token itu, dan penyeranglah yang mengendalikan header tersebut. Dua serangan klasik lahir dari memercayainya. Yang pertama adalah alg yang diset none: token tanpa tanda tangan yang diterima sebagai valid oleh sebagian pustaka lama. Yang kedua adalah algorithm confusion: server yang mengharapkan RS256 tetapi membiarkan header memilih algoritmanya bisa diberi token HS256 yang ditandatangani dengan kunci publik server itu sendiri sebagai secret HMAC. Kunci publik bersifat publik, jadi penyerang bisa menandatangani apa pun.
Pustaka modern bertahan terhadap keduanya, dan jose menolak token tanpa pengamanan di jwtVerify serta memeriksa bahwa tipe kunci cocok dengan algoritmanya. Jangan hanya bersandar pada nilai default. Kunci daftar algoritma secara eksplisit di setiap pemanggilan verify, sehingga refaktor di masa depan atau penggantian pustaka tidak bisa diam-diam melebarkannya. Jika Anda menerima lebih dari satu algoritma, misalnya selama migrasi kunci, sebutkan keduanya secara eksplisit dan gunakan kunci terpisah untuk masing-masing. Saat Anda merotasi kunci, taruh kid di header dan pilih kunci berdasarkan kid dari daftar Anda sendiri, jangan pernah dari URL yang disebutkan di header token.
Secret penandatanganan yang lemah
Token HS256 ditandatangani dengan secret bersama. Jika secret itu pendek atau mudah ditebak, seperti 'secret', nama aplikasi, atau nilai yang disalin dari sebuah tutorial, penyerang yang punya satu token valid saja bisa mem-brute-force-nya secara offline dengan perkakas biasa lalu menandatangani token untuk pengguna mana pun. Tidak ada rate limit pada penebakan offline.
- Gunakan setidaknya 32 byte acak untuk HS256, dibangkitkan dengan sesuatu seperti openssl rand -base64 32.
- Muat secret dari lingkungan atau secret manager, dan gagalkan saat startup jika ia tidak ada atau terlalu pendek.
- Jangan pernah meng-commit-nya, dan rotasi jika ia pernah ter-commit.
- Jika beberapa layanan perlu memverifikasi token tetapi hanya satu yang boleh menerbitkannya, gunakan algoritma asimetris seperti RS256 atau EdDSA agar pihak yang memverifikasi hanya memegang kunci publik.
Validasi exp, aud, dan iss
Tanda tangan yang valid hanya membuktikan siapa yang menerbitkan token itu. Klaimlah yang menentukan apakah ia memang ditujukan untuk Anda dan apakah ia masih berlaku. Token tanpa masa kedaluwarsa berlaku selamanya. Token yang diterbitkan untuk API mobile Anda tidak seharusnya diterima layanan admin Anda hanya karena keduanya memercayai penyedia identitas yang sama. Untuk itulah aud dan iss ada.
import { SignJWT, jwtVerify } from 'jose';
const rawSecret = process.env.JWT_SECRET;
if (!rawSecret || rawSecret.length < 32) {
throw new Error('JWT_SECRET must be set and at least 32 characters');
}
const secret = new TextEncoder().encode(rawSecret);
const ISSUER = 'https://api.example.com';
const AUDIENCE = 'https://app.example.com';
export function signAccessToken(userId: string, tokenVersion: number) {
return new SignJWT({ tv: tokenVersion })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(userId)
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setIssuedAt()
.setExpirationTime('10m')
.sign(secret);
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: ISSUER,
audience: AUDIENCE,
requiredClaims: ['exp', 'sub'],
});
return payload;
}jose memeriksa exp setiap kali klaim itu ada, tetapi token tanpa exp justru akan lolos. Opsi requiredClaims menutup celah tersebut. Untuk token dari penyedia identitas eksternal, verifikasi terhadap key set yang mereka terbitkan dengan createRemoteJWKSet dan tetap kunci algoritma, issuer, serta audience-nya.
localStorage atau cookie httpOnly
Menyimpan token di localStorage membuatnya bisa dibaca skrip mana pun di halaman itu. Satu bug XSS, atau satu skrip pihak ketiga yang disusupi, dan token itu bisa dikirim ke penyerang lalu dipakai dari mana saja sampai ia kedaluwarsa.
Cookie httpOnly tidak bisa dibaca JavaScript. XSS tetap serius, karena skrip yang disuntikkan bisa membuat permintaan atas nama pengguna selama halaman terbuka, tetapi ia tidak bisa mencuri kredensial berumur panjang lalu memutarnya kembali belakangan. Untuk aplikasi browser yang berbicara dengan backend-nya sendiri, cookie adalah default yang lebih baik. Untuk single-page app yang memanggil API terpisah, menyimpan access token hanya di memori dan refresh token di cookie httpOnly adalah jalan tengah yang masuk akal.
// Express: cookie sesi dengan atribut yang aman
res.cookie('__Host-session', accessToken, {
httpOnly: true, // tidak bisa dibaca dari JavaScript
secure: true, // hanya HTTPS; disyaratkan oleh prefiks __Host-
sameSite: 'lax', // tidak dikirim pada POST lintas situs
path: '/', // disyaratkan oleh prefiks __Host-
maxAge: 10 * 60 * 1000, // milidetik, cocok dengan masa berlaku token
});Prefiks __Host- memberi tahu browser untuk menolak cookie itu kecuali ia Secure, punya path yang diset ke /, dan tidak punya atribut Domain, yang mencegah subdomain yang disusupi menimpanya.
SameSite dan apa yang tidak dicakupnya
SameSite=Lax memblokir cookie pada permintaan POST lintas situs dan pada pemuatan subresource, yang menghilangkan sebagian besar CSRF klasik. Ia tetap mengirim cookie pada navigasi GET tingkat atas, sehingga endpoint GET mana pun yang mengubah state tetap terpapar. Strict memblokir itu juga, tetapi sekaligus mengeluarkan pengguna ketika mereka mengikuti tautan ke aplikasi Anda dari email atau situs lain. None menonaktifkan perlindungannya dan mensyaratkan Secure.
Lax ditambah aturan bahwa permintaan GET tidak pernah mengubah state adalah dasar yang kokoh. Untuk mutasi sensitif, tambahkan cek header Origin atau token CSRF. Ingat bahwa SameSite memperlakukan semua subdomain dari domain terdaftar Anda sebagai situs yang sama, sehingga subdomain yang rentan tetap bisa memalsukan permintaan.
Rotasi, pencabutan, dan logout sungguhan
JWT stateless tidak bisa dicabut sebelum kedaluwarsa; itulah pertukaran karena tidak memeriksa database pada setiap permintaan. CWE-613 menggambarkan apa yang terjadi ketika pertukaran itu diabaikan: logout, penggantian kata sandi, dan penangguhan akun yang sebenarnya tidak mengakhiri akses. Menghapus akun, mengubah role, dan mengeluarkan seseorang dari tim juga merupakan peristiwa pencabutan, dan masing-masing harus berlaku pada permintaan berikutnya, bukan pada saat token berikutnya kedaluwarsa.
- Jaga access token tetap berumur pendek, dalam rentang menit, bukan hari.
- Simpan refresh token di sisi server, dalam bentuk hash, dan rotasi setiap kali dipakai.
- Jika refresh token yang sudah dirotasi dipakai lagi, perlakukan sebagai pencurian dan cabut seluruh keluarga token tersebut.
- Tambahkan versi token pada baris user dan pada token; naikkan saat logout-di-semua-perangkat, penggantian kata sandi, atau penangguhan.
- Bangkitkan ulang identifier sesi saat login untuk mencegah session fixation.
export async function requireUser(token: string) {
const payload = await verifyAccessToken(token);
const user = await db.user.findUnique({
where: { id: payload.sub },
select: { id: true, tokenVersion: true, disabled: true },
});
// Versi yang dinaikkan atau akun yang dinonaktifkan langsung mengakhiri akses
if (!user || user.disabled || user.tokenVersion !== payload.tv) {
throw new Error('Session revoked');
}
return user;
}Lookup itu mengembalikan satu pembacaan database per permintaan, yang merupakan harga jujur dari kemampuan mencabut akses. Banyak aplikasi menjadi lebih sederhana dan lebih aman dengan session ID opaque di dalam cookie dan sebuah tabel sesi. Logout kemudian berarti menghapus satu baris. Pakailah JWT di tempat sifat-sifatnya benar-benar membantu, seperti token berumur pendek antar-layanan, bukan karena ia menjadi default di sebuah tutorial.
Apa pun yang Anda pilih, logout harus terjadi di server. Menghapus cookie atau menghapus token di browser hanya menghilangkan salinan milik pengguna; salinan yang dicuri tetap bekerja sampai server menolaknya. Saat logout, hapus sesi atau refresh token di sisi server, bersihkan cookie memakai nama, path, dan atribut yang sama seperti saat ia diset, lalu naikkan versi token jika pengguna memilih keluar dari semua perangkat. Reset kata sandi juga harus mengakhiri setiap sesi lainnya.
Daftar periksa review singkat
- Cari pemanggilan decode di jalur autentikasi.
- Pastikan setiap pemanggilan verify mengunci algoritma, issuer, dan audience, serta mewajibkan exp.
- Periksa bagaimana secret penandatanganan dibangkitkan, dimuat, dan divalidasi saat startup.
- Temukan di mana token disimpan di browser.
- Uji bahwa logout, penggantian kata sandi, dan penangguhan akun mengakhiri sesi yang sedang berjalan.
Pemeriksaan ini sebagian besar soal membaca jalur kode dari ujung ke ujung, yang juga merupakan cara CodeAuditAgent menjalankan sebuah audit: temuan datang dengan baris yang dikutip, CWE, skenario eksploit, dan sebuah patch, sehingga cek audience yang hilang bisa dikonfirmasi dan diperbaiki dalam hitungan menit.