Skip to content
Kembali ke Blog
Keamanan

JWT invalid signature: Semua Penyebab dan Cara Memperbaikinya

Penyebab tanda tangan JWT tidak valid (invalid signature): byte kunci berbeda antar bahasa, baris baru di .env, algoritma tidak cocok. Ada dekoder JWT online.

14 menit membaca

JWT invalid signature: Semua Penyebab dan Cara Memperbaikinya

Error JWT invalid signature berarti tepat satu hal: tanda tangan yang dihitung verifier Anda tidak sama dengan tanda tangan yang dibawa token. Itu saja isi pesannya. Token kedaluwarsa dan izin pengguna yang kurang tidak menghasilkan error ini, dan pustaka JWT Anda juga baik-baik saja. Ada sesuatu pada byte yang masuk ke HMAC, atau pada kunci publik yang masuk ke pemanggilan verify, yang berbeda antara sisi yang menandatangani dan sisi yang memeriksa.

Biang keladinya hampir selalu materi kunci (key material), bukan tokennya. Pakai ini untuk menentukan titik awal Anda:

Algoritma apa yang tertulis di header?
├─ HS256 / HS384 / HS512  → hampir selalu masalah secret
│    ├─ penanda tangan dan verifier beda bahasa?      → Bagian 3
│    └─ bahasa sama, jalan di lokal, gagal di produksi? → Bagian 4
└─ RS256 / ES256 / PS256  → hampir selalu format kunci atau salah kunci
     └─ → Bagian 7

Token melewati gateway, proxy, atau salin-tempel?          → Bagian 6
Error muncul hanya setelah beberapa jam atau di satu host? → Bagian 8

Setiap bagian di bawah berakhir dengan sesuatu yang bisa langsung Anda jalankan. Kalau Anda ingin langkah pertama yang paling cepat, tempelkan token ke dekoder JWT lalu baca field alg, karena separuh cabang di atas langsung runtuh begitu Anda tahu nilainya.

1. Apa isi pesan “invalid signature”

Pustaka yang berbeda mencetak string yang berbeda untuk kegagalan yang sama persis. Temukan milik Anda di daftar ini supaya yakin Anda sedang membaca panduan yang tepat:

  • Node jsonwebtoken: JsonWebTokenError: invalid signature
  • Python PyJWT: InvalidSignatureError: Signature verification failed
  • Java jjwt: SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.

Ketiganya muncul pada momen yang sama, di jalur kode yang sama. Pustaka mengambil dua segmen pertama token Anda, menghitung ulang tanda tangannya dengan kunci yang Anda berikan, lalu membandingkan hasilnya byte demi byte dengan segmen ketiga. Tidak sama, lempar error.

Perbandingannya persis, dan sama sekali tidak membawa informasi tentang seberapa berbeda kedua nilai itu. Selisih satu byte pada secret dan kunci yang sepenuhnya salah menghasilkan pesan error yang identik. Karena itulah sisa panduan ini berfokus pada mempersempit ruang input, bukan pada membaca pesan error dengan lebih teliti.

Perhatikan juga apa yang belum terjadi saat error ini muncul. Validasi klaim (claim) berjalan setelah verifikasi tanda tangan, jadi pustaka belum melihat exp, nbf, aud, dan iss sama sekali. Kalau verifikasi tanda tangan JWT Anda gagal, isi token tidak relevan untuk diagnosis, meskipun isinya tetap bisa dibaca, karena JWT itu dikodekan (encoded), bukan dienkripsi. Mendekode header dan payload sama sekali tidak butuh kunci; lihat cara mendekode token JWT kalau Anda ingin penelusuran segmen per segmen.

Dua field di header menentukan ke mana Anda melangkah berikutnya: alg memberi tahu apakah Anda sedang memburu secret bersama atau sepasang kunci, dan kid menunjukkan kunci mana yang menurut penanda tangan ia pakai.

2. Tanda tangan mencakup string terkode, bukan objek Anda

Developer paling sering salah memahami bagian ini, padahal model mentalnya mendasari semua hal lain di panduan ini. RFC 7515, spesifikasi JSON Web Signature, mendefinisikan JWS Signing Input sebagai string ASCII:

BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload)

HMAC bekerja atas string itu, bukan atas map claims Anda dan bukan atas apa pun yang bahasa Anda anggap sebagai data terstruktur. Artikel ini memakai signing input berikut, yang berasal dari payload contoh standar:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ

Konsekuensinya berat dan terus-menerus menjebak banyak tim: lapisan mana pun yang mendekode payload lalu mengodekannya ulang akan merusak tanda tangan. Serialisasi JSON tidak bersifat kanonik. Urutan kunci berubah ketika sebuah map bolak-balik melewati sebagian besar bahasa. Spasi muncul atau hilang. Satu serializer meng-escape karakter non-ASCII menjadi \uXXXX, serializer lain mencetaknya apa adanya. Angka berubah format, sehingga 1516239022 bisa kembali sebagai 1516239022.0. Setiap satu dari itu menghasilkan string base64url yang berbeda, maka signing input yang berbeda, maka tanda tangan yang berbeda.

Pemicu nyata yang pernah kami temui:

  • Sebuah API gateway yang mem-parsing JWT untuk menambahkan tenant ID lalu memancarkan ulang tokennya.
  • Middleware logging atau tracing yang “merapikan” header dan menulis ulang nilai Authorization.
  • Developer yang mem-pretty-print token supaya bisa dibaca, lalu menempelkan kembali versi rapinya.

Kalau ada komponen di antara penanda tangan dan verifier Anda yang bisa menulis ulang token, komponen itulah tersangka pertama. Token adalah string buram (opaque) selama perjalanan; operasi yang aman hanya menyimpan, menyalin, dan membandingkan.

3. Secret yang sama, byte yang berbeda

Hampir tidak ada artikel troubleshooting yang membahas penyebab ini, padahal ia ada di balik setiap laporan bug bernada “secret-nya benar-benar identik kok, sudah saya diff”.

HMAC tidak memakan string. HMAC memakan byte. File konfigurasi Anda, secrets manager Anda, dan variabel lingkungan (environment variable) Anda semuanya menyimpan string. Ada sesuatu yang harus mengubah yang satu menjadi yang lain, dan konversi itu tidak punya standar lintas pustaka JWT. Dua layanan bisa memegang secret yang identik karakter demi karakter dan tetap menghitung tanda tangan yang berbeda.

Ini buktinya. Kami menghitungnya secara lokal terhadap signing input dari Bagian 2, dengan string secret sepanjang 36 karakter:

c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm
Cara byte ditafsirkanByteIsi kunci yang sebenarnyaTanda tangan HS256 yang dihasilkan
Diperlakukan sebagai teks UTF-83636 karakter kasatmata itu sendiritUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak
Di-base64-decode lebih dulu27secret-key-1234567890abcdef53ISDuciq-ov8YF1Ezwy6zo6KUO-1tpwz2oW7gVPuIM

String secret-nya sama, algoritmanya sama, payload-nya sama. Dua tanda tangan yang keluar tidak punya kemiripan sama sekali. Sisi mana pun yang “salah” akan melaporkan invalid signature, dan sebanyak apa pun Anda mem-diff file konfigurasi tidak akan mengungkap apa-apa, karena file konfigurasinya memang cocok.

Token lengkap untuk pembacaan UTF-8, kalau Anda ingin mereproduksinya:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak

Tempelkan ke dekoder JWT bersama secret di atas, dan token itu terverifikasi. Dekode dulu secret-nya sebagai base64, dan token itu tidak terverifikasi.

Bagaimana tiap pustaka mengubah string menjadi byte kunci

Berpeganglah pada yang terdokumentasi. Tabel di bawah sengaja kami buat sempit, dan kolom terakhir lebih penting daripada kolom pertama.

Runtime / pustakaPerilaku string ke byteSiapa yang memutuskan
Node jsonwebtokenbyte UTF-8 dari stringpustaka
Python PyJWTbyte UTF-8 dari stringpustaka
Java jjwt, overload String lawascodec base64 bawaan platform, sesuai jwtk/jjwt#204pustaka
Go golang-jwtmenerima []byte secara langsungAnda, di call site
.NETmenerima byte[] secara langsungAnda, di call site

Baris Java adalah sumber historis kepusingan lintas-stack ini, dan duduk perkaranya perlu jelas. Pada versi jjwt lama, signWith(SignatureAlgorithm, String) dan saudara-saudaranya menjalankan String melalui codec base64 alih-alih mengambil byte mentahnya, sementara overload byte[] memakai byte apa adanya. Karena itu layanan Node dan layanan Java yang berbagi satu secret jadi tidak sepakat. API String tersebut sudah deprecated sejak jjwt 0.10, dan bentuk modernnya bersifat eksplisit:

SecretKey key = Keys.hmacShaKeyFor(secretBytes);

Ini bukan “begitulah cara Java menangani JWT”. Ini satu overload lawas milik satu pustaka, dan kode jjwt masa kini yang mengoper byte[] sama sekali tidak ambigu. Laporan cerminnya di sisi Node adalah auth0/node-jsonwebtoken#208, di mana token yang ditandatangani di Java tidak mau terverifikasi di Node. Ada laporan serupa terhadap firebase/php-jwt milik PHP (lihat firebase/php-jwt#153), meski kami sendiri belum memverifikasi cara pustaka itu menangani byte, jadi perlakukan sebagai petunjuk, bukan sebagai diagnosis.

Go dan .NET masuk keranjang yang berbeda. Tidak satu pun dari kedua pustaka itu memutuskan atas nama Anda; keduanya menyerahkan parameter []byte / byte[] lalu mundur. []byte(secret) dan Encoding.UTF8.GetBytes(secret) menghasilkan UTF-8, sedangkan Convert.FromBase64String(secret) menghasilkan byte hasil dekode. Bug-nya, kalau memang terjadi, hidup di call site Anda sendiri, dan itu kabar baik: bug itu terlihat di diff Anda sendiri.

Apakah secret JWT saya base64 atau UTF-8?

Tidak ada penanda apa pun di dalam token yang memberi tahu Anda. Anda harus menalar dari string-nya sendiri:

  1. Apakah isinya hanya A–Z a–z 0–9 + / = (atau - dan _)? Kalau ya, string itu mungkin base64. Secret yang mengandung spasi, !, atau # tidak mungkin base64.
  2. Apakah panjangnya kelipatan 4, atau berakhir dengan padding =? Keduanya petunjuk kuat bahwa ada sesuatu yang meng-encode-nya ke base64 di perjalanan masuk.
  3. Apakah hasil base64-decode-nya berupa byte yang masuk akal? Jalankan lewat dekoder Base64. ASCII yang terbaca, atau tepat 32 byte yang tampak acak, menandakan base64. Mojibake menandakan string itu memang tidak pernah di-encode.

Secret seperti c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm lolos ketiga tes tadi, dan justru itulah yang membuatnya berbahaya: string itu ambigu, dan kedua pembacaannya sama-sama masuk akal. Secret yang mengandung - atau _ ambigu dengan cara yang lebih jahat lagi, karena valid sebagai base64url tetapi tidak valid sebagai base64 standar.

Kalau Anda tidak bisa menalar sampai ketemu jawabannya, hitung saja keduanya. Ambil signing input, jalankan HMAC-SHA256 atasnya dua kali di generator HMAC, sekali dengan secret sebagai teks dan sekali dengan byte hasil dekode, lalu bandingkan masing-masing hasilnya dengan segmen ketiga token. Salah satunya akan cocok, dan itu memberi tahu Anda sisi mana dari sistem Anda yang benar.

Karakter bukanlah byte

Jebakan sejenisnya adalah menghitung karakter padahal spesifikasinya berbicara dalam byte. RFC 7518 §3.2 menyatakan batas bawah kunci untuk HMAC-SHA dalam bit, bukan dalam karakter, dan teks terkode itu memuai:

Cara Anda menuliskannyaEntropiSetara byteUntuk HS256 (butuh ≥256 bit)
32 karakter hex128 bit16 byte❌ di bawah batas
32 karakter base64192 bit24 byte❌ di bawah batas
32 byte acak256 bit32 byte✅ memenuhi (64 karakter dalam hex, 44 dalam base64 dengan padding)

Sebuah “secret 32 karakter” bisa berarti apa saja antara 128 sampai 256 bit tergantung alfabetnya. Hal ini ortogonal terhadap masalah penafsiran byte di atas, tetapi menggigit orang yang sama, karena tim yang mengukur dalam karakter biasanya tim yang tidak pernah melihat byte-nya. Untuk aturan pemilihannya (panjang, encoding, rotasi), bukalah generator secret JWT; catatan rujukannya sudah membahas itu dengan benar dan tidak ada alasan mengulanginya di sini.

4. Secret-nya sendiri sudah terkontaminasi

Kedua layanan Anda sepakat soal penafsiran byte. Tanda tangannya tetap gagal. Sekarang periksa apakah secret yang dimuat masing-masing sisi memang secret yang Anda kira Anda tulis, karena perpipaan lingkungan (environment plumbing) luar biasa jago menambahkan satu byte.

Baris baru di akhir .env. Sebagian pembaca memuat JWT_SECRET=abc yang diikuti pergantian baris sebagai abc\n. Satu byte ekstra, dan HMAC menghasilkan keluaran yang sama sekali tidak berhubungan. Tidak ada kemiripan parsial yang bisa Anda sadari.

Tanda kutip terbaca sebagai data. JWT_SECRET="abc" berarti abc bagi sebagian loader dan "abc" bagi yang lain, terutama ketika shell mem-source file itu sementara pustaka mem-parsing-nya. env_file milik Docker Compose dan sebuah parser .env bisa berbeda pendapat atas file yang sama.

Karakter tak kasatmata dari salin-tempel. Menyalin secret dari Slack, wiki, atau PDF bisa ikut menyeret zero-width space (U+200B, byte e2 80 8b) atau non-breaking space (U+00A0, byte c2 a0). Keduanya tak terlihat di editor mana pun dan keduanya mengubah HMAC.

Pengacauan dari CI dan kontainer. Secret yang lewat interpolasi shell bisa kena ekspansi $ atau backslash-nya dimakan. Sebagian sistem CI memangkas nilai, sebagian tidak. Secret Kubernetes berbentuk base64 di manifest dan mentah di dalam kontainer, sebuah jebakan dekode ganda tersendiri.

Perbaikannya adalah berhenti memandangi secret dan mulai mengukurnya. Di tiap sisi, cetak panjangnya dan sebuah sidik jari (fingerprint), jangan pernah nilainya:

printf '%s' "$JWT_SECRET" | wc -c
printf '%s' "$JWT_SECRET" | shasum -a 256 | cut -c1-16

Jalankan kedua perintah itu di penanda tangan dan di verifier lalu bandingkan kedua keluarannya. Panjang cocok dan sidik jari cocok berarti secret bukan masalah Anda, jadi kembalilah ke Bagian 3. Panjang yang lebih besar satu dari yang Anda harapkan adalah baris baru di akhir. Lebih besar dua adalah tanda kutipnya.

Kalau panjangnya meleset dan Anda ingin melihat persis apa yang ada di dalamnya, buat hex dump di shell lokal terhadap secret pengembangan:

printf '%s' "$JWT_SECRET" | xxd

0a di ujung berarti baris baru. 22 di awal dan di akhir berarti sepasang karakter kutip. c2 a0 atau e2 80 8b di tengah berarti kasus karakter tak kasatmata. Jangan jalankan ini terhadap secret produksi di mesin yang mengirimkan keluaran terminalnya ke mana pun.

Pemeriksaan setara di dalam proses Node atau Python yang sedang berjalan:

const s = process.env.JWT_SECRET ?? '';
console.log(Buffer.byteLength(s, 'utf8'), JSON.stringify(s.slice(-3)));
import os
s = os.environ["JWT_SECRET"]
print(len(s), len(s.encode("utf-8")), repr(s[-3:]))

Di Python, len(s) yang menghitung lebih sedikit daripada len(s.encode("utf-8")) memberi tahu Anda bahwa ada karakter non-ASCII di dalam secret yang seharusnya ASCII.

5. Algoritma dan tipe kunci tidak cocok

Header alg dan kunci yang Anda oper harus berasal dari keluarga yang sama. HS256 menuntut secret bersama, yaitu deretan byte. RS256 dan ES256 menuntut kunci asimetris, yaitu PEM atau JWK. Salah menyilangkan kabelnya dan Anda dapat kegagalan yang rentangnya dari type error yang jelas sampai invalid signature polos, tergantung seberapa pemaaf pustakanya.

Bentuk umum masalah ini:

  • Header menyebut HS256, dan verifier menyerahkan kunci publik PEM ke pustaka. Sebagian pustaka meng-HMAC teks PEM itu lalu melaporkan tanda tangan tidak cocok.
  • Header menyebut RS256, dan verifier menyerahkan string secret HMAC.
  • Verifier tidak mengoper daftar algoritma sama sekali dan membiarkan pustaka menyimpulkannya dari alg, sehingga pergeseran konfigurasi di sisi penanda tangan diam-diam mengubah perilaku verifier.

Yang terakhir itulah tempat bug konfigurasi berubah menjadi bug keamanan, jadi kuncilah algoritmanya secara eksplisit di setiap pemanggilan verify:

jwt.verify(token, key, { algorithms: ['HS256'] });
jwt.decode(token, key, algorithms=["HS256"])

Mengunci algoritma juga mengubah error tanda tangan yang samar menjadi error yang presisi. Kalau sebuah token datang dengan alg: RS256 sementara allowlist Anda berisi HS256, Anda dapat error algoritma eksplisit yang menyebut kedua nilainya.

Ada satu batas yang penting di sini. Bagian ini membahas salah konfigurasi: dua komponen milik Anda sendiri yang tidak sepakat, tanpa ada penyerang sama sekali. Ada kegagalan sejenis dengan bentuk yang sama, di mana penyerang menulis ulang alg dari RS256 menjadi HS256 lalu menandatangani dengan kunci publik Anda sebagai secret HMAC. Itu namanya kebingungan algoritma (algorithm confusion), itu serangan dan bukan bug, dan praktik terbaik keamanan JWT membahasnya bersama sisa model ancamannya. Pertahanannya, yaitu allowlist eksplisit, kebetulan sama persis, dan itu argumen bagus untuk menerapkannya bahkan ketika Anda cuma sedang mengejar bug.

6. Token berubah di perjalanan

Sebelum menyalahkan kunci, pastikan dulu verifier menerima string yang sama dengan yang dihasilkan penanda tangan. JWT itu rapuh persis dengan cara string itu rapuh.

Prefiks Bearer. Authorization: Bearer eyJhbGci... adalah nilai header, bukan token. Memecah dengan pemisah yang salah, atau memecah sekali lalu menyimpan potongan yang salah, membuat Anda memverifikasi Bearer eyJhbGci... atau bahkan string kosong. Kupas dengan sengaja:

const token = req.headers.authorization?.replace(/^Bearer\s+/i, '').trim();

Spasi dan pergantian baris. Token yang disalin dari terminal ikut terlipat. Token yang disimpan di YAML kena folding. Satu \n yang tertanam di dalam segmen ketiga menghasilkan ketidakcocokan tanda tangan, bukan parse error, karena dekoder base64url sering melewati spasi sementara pembandingan string tidak.

URL encoding. Token yang bepergian sebagai parameter kueri bisa kembali dengan . menjadi %2E, atau dengan - dan _ yang diterjemahkan ulang encoder yang terlalu bersemangat. Dekode sekali, tepat sekali.

Pemotongan. Cookie punya batas sekitar 4 KB masing-masing, dan token RS256 dengan beberapa klaim rutin melampauinya. Token yang terpotong biasanya gagal di dekode base64, tetapi kalau potongannya jatuh tepat di batas kelipatan 4 karakter, yang Anda dapat justru token yang tampak sah dengan tanda tangan yang salah.

Dua perintah menuntaskan hal ini. JWT yang bentuknya benar punya tepat dua titik:

printf '%s' "$TOKEN" | tr -cd '.' | wc -c

Dan setiap karakter harus ada di alfabet base64url, jadi perintah ini seharusnya tidak mencetak apa pun:

printf '%s' "$TOKEN" | tr -d 'A-Za-z0-9._-' | xxd

Keluaran apa pun dari perintah kedua langsung menamai masalah Anda: 3d adalah padding = yang seharusnya tidak ada, 2b atau 2f adalah + dan / dari base64 standar di tempat base64url menuntut - dan _, dan 20 adalah spasi nyasar.

7. Kegagalan khas RS256 dan ES256

Algoritma asimetris menukar masalah secret dengan masalah pengelolaan kunci. Mode kegagalannya cukup berbeda sampai layak punya daftar sendiri.

PKCS#1 versus PKCS#8. Keduanya adalah format wadah untuk kunci RSA yang sama, dan Anda bisa membedakannya secara kasatmata dari satu kata di baris header:

-----BEGIN RSA PRIVATE KEY-----      ← PKCS#1
-----BEGIN PRIVATE KEY-----          ← PKCS#8

Pustaka berbeda-beda dalam hal mana yang mereka terima. Kalau salah satunya menolak formatnya mentah-mentah, Anda dapat error yang jelas; kalau ia mem-parsing setengah jalan, Anda bisa dapat tanda tangan yang tidak pernah terverifikasi. Konversikan saja daripada melawannya:

openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem

Kuncinya tertukar. Menandatangani dengan kunci publik, atau memverifikasi dengan kunci privat. Kedengarannya jelas, tetapi gampang terjadi ketika kedua file duduk di direktori yang sama dengan nama yang bedanya cuma empat karakter. Pastikan mana yang mana:

openssl rsa -in key.pem -noout -text | head -1

Kunci privat mencetak ukuran modulusnya sebagai kunci privat; kunci publik akan error kecuali Anda menambahkan -pubin.

JWKS dan pergeseran kid. Dengan endpoint JWKS, verifier memilih kunci dengan mencocokkan kid dari token terhadap key set. Tiga hal bisa salah di sini: penanda tangan sudah merotasi kunci sementara JWKS yang di-cache verifier sudah basi; token tidak punya kid sehingga verifier memilih kunci pertama di set; atau dua lingkungan menerbitkan nilai kid yang saling tumpang tindih. Kalau Anda mencurigai ini, ambil JWKS yang segar dan pastikan kid persis dari header token memang ada di dalamnya.

Pengkodean (encoding) tanda tangan ES256. Tanda tangan ECDSA adalah sepasang bilangan bulat, r dan s, dan ada dua cara menyerialkannya. Tumpukan kripto serbaguna sering memancarkan DER, sebuah struktur ASN.1 dengan panjang variabel. RFC 7518 §3.4 justru menuntut bentuk JOSE: r dan s masing-masing di-padding ke panjang tetap lalu disambung, yang untuk P-256 berarti 64 byte. Tanda tangan DER yang masuk ke dalam JWT bukan sekadar salah, panjangnya pun berbeda. Jadi token ES256 yang segmen ketiganya tidak terdekode menjadi tepat 64 byte pasti berasal dari sesuatu yang melewatkan konversi itu.

Untuk memisahkan apakah masalahnya ada di kunci atau di pipeline Anda, tandatangani payload yang sama secara terpisah di encoder JWT lalu bandingkan keluarannya dengan yang dihasilkan layanan Anda. Tanda tangan identik menunjuk ke transport atau penanganan klaim. Tanda tangan berbeda menunjuk ke kunci.

8. Error yang terlihat seperti kegagalan tanda tangan padahal bukan

Sebagian pustaka memang salah memberi label pada kegagalan ini, dan begitulah mereka berakhir di laporan bug yang keliru.

GejalaSebenarnya apaDi mana harus dicari
PyJWT ExpiredSignatureErrorexp sudah lewat. Namanya menyebut signature; penyebabnya sebuah klaim.Selisih jam antar host, atau TTL yang terlalu pendek
PyJWT ImmatureSignatureErrornbf masih di masa depanJam penanda tangan lebih maju daripada jam verifier
Node TokenExpiredErrorexp sudah lewatSama seperti di atas
401 generik tanpa detailFramework meringkas semua kegagalan verifikasi jadi satu responsNyalakan logging error di level pustaka
Jalan beberapa menit, lalu gagalToken kedaluwarsa, bukan tanda tanganBandingkan iat dan exp dengan jam kedua host
Gagal hanya untuk satu audienceaud atau iss tidak cocokDaftar audience yang diharapkan verifier

Penamaan di PyJWT adalah jebakan yang paling menonjol. ExpiredSignatureError mengandung kata “signature” tetapi muncul saat validasi klaim, jauh setelah tanda tangannya lolos verifikasi. Mencari string error itu membawa Anda lurus ke bahan troubleshooting tanda tangan, dan berjam-jam lenyap di bagian masalah yang salah.

Selisih jam menghasilkan pola yang paling membingungkan dari semuanya: kegagalan yang muncul-hilang tanpa berkorelasi dengan apa pun di kode Anda. Kalau jam satu host melenceng ke depan, token yang baru diterbitkan gagal validasi nbf atau iat begitu tiba, dan kegagalannya bergeser-geser seiring melebarnya selisih. Bandingkan date -u di kedua mesin lebih dulu. Sebagian besar pustaka menerima parameter leeway, yang merupakan perbaikan tepat untuk selisih yang tidak bisa Anda hilangkan dan perbaikan keliru untuk jam yang memang rusak.

Aturan umumnya: kalau kegagalannya bergantung waktu, bergantung host, atau bergantung audience, itu bukan masalah tanda tangan. Kegagalan tanda tangan bersifat deterministik. Token dan kunci yang sama akan gagal dengan cara yang sama selamanya.

9. Alur kerja troubleshooting yang bisa diulang

Jalankan langkah-langkah ini secara berurutan. Setiap langkah entah menemukan bug-nya atau menyingkirkan satu cabang, dan berhenti lebih awal justru itulah tujuannya.

  1. Dekode header-nya. Tempelkan token ke dekoder JWT lalu catat alg dan kid. Ini menentukan semua hal di hilir dan tidak butuh kunci.
  2. Periksa bentuk token. Tepat dua titik, hanya karakter base64url, tanpa prefiks Bearer, tanpa spasi. Pakai dua perintah di Bagian 6. Ini menyingkirkan kerusakan di transport.
  3. Kunci algoritma pada pemanggilan verify. Kalau ada ketidakcocokan antara alg dan allowlist Anda, sekarang Anda dapat error eksplisit yang menyebut keduanya alih-alih error generik.
  4. Ambil sidik jari kunci di kedua sisi. Cetak panjang byte dan SHA-256 yang dipotong, di penanda tangan maupun di verifier, seperti di Bagian 4. Nilai yang berbeda berarti perpipaannya yang bersalah dan Anda tidak perlu sampai ke langkah 5.
  5. Kalau kedua sisi memakai bahasa yang berbeda, selesaikan penafsiran byte-nya. Lihat tabel di Bagian 3, putuskan secara eksplisit apakah secret itu teks atau base64, lalu buat kedua sisi menyatakannya di dalam kode, bukan lewat perilaku bawaan.
  6. Tandatangani ulang payload yang sama secara terpisah. Pakai encoder JWT dengan kunci yang Anda yakini benar lalu bandingkan segmen ketiganya dengan milik token Anda. Cocok berarti sisi penanda tangan Anda baik-baik saja dan verifier-lah masalahnya.
  7. Periksa silang HMAC-nya secara manual. Jalankan signing input lewat generator HMAC dengan kedua penafsiran byte. Mana pun yang cocok dengan token memberi tahu Anda sisi mana yang harus diubah.

Kalau Anda sudah melewati ketujuhnya dan masih butuh bantuan, kebanyakan laporan bug mandek karena menghilangkan justru fakta-fakta yang menentukan jawabannya. Sertakan ini:

  • Nilai alg dari header, dan apakah ada kid
  • Bahasa, pustaka, dan versi persisnya di kedua sisi, penanda tangan maupun verifier
  • Panjang byte secret di kedua sisi, dan 16 karakter hex pertama dari SHA-256-nya (jangan pernah secret-nya sendiri)
  • Apakah Anda menyimpan secret sebagai teks atau base64, dan bagaimana tiap sisi mengonversinya
  • Signing input lengkap. Dua segmen pertama tidak sensitif; siapa pun yang memegang token toh bisa membacanya
  • Untuk RS256 dan ES256: baris header PEM-nya, apa adanya

Daftar itu mengubah “tanda tangan JWT saya tidak cocok” yang mustahil dijawab menjadi pertanyaan yang benar-benar bisa diselesaikan orang, biasanya dalam satu balasan.

FAQ

Kenapa secret yang sama jalan di satu bahasa tapi gagal di bahasa lain?

Karena pustaka tidak sepakat soal cara mengubah string secret menjadi byte kunci. Node jsonwebtoken dan Python PyJWT memakai UTF-8; overload String lawas milik jjwt memakai codec base64 (jwtk/jjwt#204); Go dan .NET menyerahkan keputusannya ke call site Anda. Karakter sama, byte berbeda, HMAC berbeda.

Apakah tanda tangan mencakup payload yang sudah didekode atau string terkodenya?

String terkodenya. RFC 7515 mendefinisikan signing input sebagai base64url(header) + "." + base64url(payload) dalam ASCII harfiah. Lapisan mana pun yang mendeserialisasi payload lalu menyerialkannya ulang akan mengubah urutan kunci, spasi, atau format angka, sehingga menghasilkan string berbeda dan karenanya tanda tangan berbeda.

Secret saya kelihatan seperti base64 — haruskah saya mendekodenya sebelum menandatangani?

Hanya kalau sisi seberang juga melakukannya. Tidak ada jawaban yang benar secara terpisah; syaratnya adalah kedua ujung sepakat. Periksa apakah string itu hanya memakai karakter base64 dan panjangnya kelipatan empat, lalu buat pilihannya eksplisit di dalam kode di kedua sisi alih-alih mengandalkan perilaku bawaan.

Bisakah satu baris baru di akhir .env benar-benar merusak tanda tangan?

Bisa. HMAC memakan byte, dan abc\n itu empat byte sedangkan abc tiga byte. Tanda tangan yang dihasilkan tidak punya kemiripan apa pun dengan yang benar. Cetak printf '%s' "$JWT_SECRET" | wc -c di kedua host; panjang yang lebih besar satu dari yang diharapkan hampir selalu berarti ini.

Bagaimana saya tahu masalahnya ada di secret atau di algoritma?

Baca alg dari header lebih dulu. Kalau diawali HS, Anda butuh secret bersama dan sebuah PEM pasti gagal. Kalau diawali RS, PS, atau ES, Anda butuh sepasang kunci dan string secret pasti gagal. Begitu alg dan tipe kunci berada di keluarga yang sama, sisa kegagalannya adalah masalah isi kunci.

Kenapa jwt.io bilang tanda tangannya valid tapi server saya menolaknya?

Karena alat online dan server Anda bisa menafsirkan secret secara berbeda: yang satu sebagai teks UTF-8, yang lain sebagai base64. Alat itu memvalidasi terhadap byte yang ia turunkan sendiri, bukan byte yang diturunkan server Anda. Selain itu, jangan pernah menempelkan secret produksi ke situs pihak ketiga; pakai kunci pengembangan.

Apakah “invalid signature” pernah disebabkan oleh token yang kedaluwarsa?

Tidak. Verifikasi tanda tangan berjalan sebelum validasi klaim, jadi kedaluwarsa tidak pernah jadi penyebabnya. Kedaluwarsa muncul terpisah sebagai TokenExpiredError di Node atau ExpiredSignatureError di PyJWT. Nama yang terakhir itu menyesatkan, karena tanda tangannya terverifikasi baik-baik saja dan hanya exp yang gagal.

Kesimpulan

Ketidakcocokan tanda tangan hampir tidak pernah merupakan masalah kriptografi. HMAC-SHA256 bekerja. RSA bekerja. Yang gagal adalah perbatasan tempat string berubah menjadi byte: codec base64 di satu sisi dan UTF-8 di sisi lain, config loader yang mempertahankan satu baris baru, gateway yang dengan penuh niat baik menyerialkan ulang payload. Setiap penyebab di panduan ini adalah perselisihan tentang byte.

Jadi buatlah byte-nya eksplisit dan berhentilah mengandalkan perilaku bawaan. Tuliskan, di dokumentasi tim Anda, apakah tim menyimpan secret bersama sebagai teks mentah atau sebagai base64, dan minta setiap layanan mengonversinya dengan cara yang sudah dideklarasikan itu alih-alih mewarisi apa pun yang diasumsikan pustakanya. Untuk sistem yang membentang di beberapa bahasa, simpan secret dalam hex atau base64 lalu dekode secara eksplisit di setiap call site: satu baris per layanan, dan ambiguitasnya lenyap. Lalu tambahkan sidik jari panjang byte dari Bagian 4 ke health check Anda, supaya ketidakcocokan berikutnya muncul sebagai peringatan saat startup, bukan sebagai 401 di produksi.

Tag: jwt authentication debugging hmac api-security

Artikel Terkait

Lihat semua artikel