JWT Doğrularken Yapılan Beş Hata ve Doğru Doğrulama

Kararı token değil sunucu verir
Özet (TL;DR)
jwt.decodeimzaya bakmaz. Payload base64url ile kodlanmıştır, şifrelenmemiştir. Token'a ne yazarsanız token'ı gören herkes okur.- Kabul edilecek algoritmaları sunucu belirler. Header'daki
alg, bu listeyle karşılaştırılacak bir iddiadan ibarettir. kidsaldırganın da yazabildiği bir metindir. Dosya yoluna, SQL sorgusuna ya da URL'ye girmez, yalnızca bilinen anahtarlar arasında aranır.jsonwebtokendejosedaexpalanı olmayan token'ı süresiz kabul eder.exp,issveaudkontrolünü siz açıkça istemelisiniz.- İmzalı bir token süresi dolmadan geri alınamaz. İptal gerekiyorsa kısa ömürlü access token ile sunucuda tutulan, her kullanımda değişen bir refresh token birlikte çalışır.
Diyelim ki bir API'nin erişim loglarında tuhaf bir istek gördünüz. Authorization başlığındaki token'ın son parçası boş, payload'da "rol":"admin" yazıyor ve sunucu isteğe 200 dönmüş. Kimse anahtarınızı çalmadı. Doğrulama kodu token'a ne yapması gerektiğini sordu, token da cevap verdi.
JWT hatalarının neredeyse hepsi bu kökten çıkar: doğrulayıcı kararı saldırganın yazdığı alanlara bırakır. Aşağıda beş yaygın hatayı Node üzerinde yerelde tetikleyip her birini kapatan doğrulayıcıyı kuracağız. Örnekler jsonwebtoken 9.0.0 ve jose 4.14.4 ile Node 20'de çalıştı.
decode ile verify aynı şey değil
JWT üç parçadan oluşur: header, payload ve imza. İlk ikisi JSON'un base64url ile yazılmış hâlidir. Base64url şifreleme değil, URL'de sorun çıkarmasın diye + ve / yerine - ve _ kullanan, padding'siz bir Base64 türü. Bir token'ı JWT Decoder aracına yapıştırdığınızda ya da ortadaki parçayı Base64 Kodlama ve Çözme aracından geçirdiğinizde içeriği anahtarsız görürsünüz. Aynı şeyi Node'da deneyelim, üstüne payload'u da kurcalayalım.
// 01-decode.jsconst jwt = require('jsonwebtoken'); const token = jwt.sign( { sub: '42', rol: 'kullanici', tckn: '12345678901' }, 'cok-gizli-bir-anahtar-en-az-32-bayt!!', { algorithm: 'HS256', expiresIn: '15m' }); // İmzayı bilmeden payload'u okumak için kütüphane bile gerekmiyorconst [, payload] = token.split('.');console.log(Buffer.from(payload, 'base64url').toString()); // Payload'u değiştirip imzayı olduğu gibi bırakalımconst sahte = JSON.parse(Buffer.from(payload, 'base64url'));sahte.rol = 'admin';const [h, , s] = token.split('.');const kurcalanmis = [h, Buffer.from(JSON.stringify(sahte)).toString('base64url'), s].join('.'); console.log('decode:', jwt.decode(kurcalanmis).rol);try { jwt.verify(kurcalanmis, 'cok-gizli-bir-anahtar-en-az-32-bayt!!', { algorithms: ['HS256'] });} catch (e) { console.log('verify:', e.message);}$ node 01-decode.js{"sub":"42","rol":"kullanici","tckn":"12345678901","iat":1687900080,"exp":1687900980}decode: adminverify: invalid signaturejwt.decode kurcalanmış token'dan admin döndürdü, çünkü decode yalnızca metni çözer. İmzayı kontrol eden verify'dır. Yetki kararını decode çıktısıyla verdiğiniz her satır, token'ı elle yazan herkese açık bir kapıdır. decode'un meşru bir kullanımı var: doğrulamadan önce header'daki kid alanını okuyup hangi anahtarla doğrulayacağınızı seçmek. O da üçüncü hatanın konusu.
Çıktının ilk satırı ikinci bir ders veriyor. tckn alanını token'ın uğradığı her yer görür: tarayıcı geçmişi, proxy logları, hata izleme servisleri, destek ekibine atılan ekran görüntüleri. JWT'ye kimlik numarası, e-posta ya da iç sistemlere ait ayrıntı koymayın. İçerik gerçekten gizli kalmalıysa JWE (şifreli JWT) diye ayrı bir biçim var. Ben önce payload'u küçültmeyi denerim: bir sub, gereken birkaç claim ve o kadar.
1. alg: none kabul etmek
RFC 7519, imzası olmayan JWT'ye izin verir. Bütünlüğün başka bir katmanda sağlandığı durumlar için tanımlanmış bu biçimde header "alg":"none" der ve üçüncü parça boştur. Header'ı okuyup ona göre davranan bir doğrulayıcı, token'ı imzası olmadığı için kontrol etmeden geçirir. Tim McLean 2015'te bazı JWT kütüphanelerinin tam olarak bunu yaptığını gösterdi. Hatayı görmek için bilerek naif bir doğrulayıcı yazalım.
// 02-naif.jsconst crypto = require('crypto'); const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); // Naif doğrulayıcı: algoritmayı token'ın kendisinden okuyorfunction naifVerify(token, anahtar) { const [h, p, s] = token.split('.'); const header = JSON.parse(Buffer.from(h, 'base64url')); if (header.alg === 'none') { return JSON.parse(Buffer.from(p, 'base64url')); // imza yok, sorun yok (!) } if (header.alg === 'HS256') { const beklenen = crypto.createHmac('sha256', anahtar).update(`${h}.${p}`).digest('base64url'); if (beklenen !== s) throw new Error('imza geçersiz'); } else if (header.alg === 'RS256') { const ok = crypto.verify('sha256', Buffer.from(`${h}.${p}`), anahtar, Buffer.from(s, 'base64url')); if (!ok) throw new Error('imza geçersiz'); } else { throw new Error('desteklenmeyen alg'); } return JSON.parse(Buffer.from(p, 'base64url'));} const { publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });const pem = publicKey.export({ type: 'spki', format: 'pem' }); // Saldırı 1: alg noneconst none = `${b64({ alg: 'none', typ: 'JWT' })}.${b64({ sub: '1', rol: 'admin' })}.`;console.log('none ->', naifVerify(none, pem)); // Saldırı 2: RS256 bekleyen sunucuya HS256 token, HMAC anahtarı = public key PEM'iconst h = b64({ alg: 'HS256', typ: 'JWT' });const p = b64({ sub: '1', rol: 'admin' });const s = crypto.createHmac('sha256', pem).update(`${h}.${p}`).digest('base64url');console.log('HS256 ->', naifVerify(`${h}.${p}.${s}`, pem));$ node 02-naif.jsnone -> { sub: '1', rol: 'admin' }HS256 -> { sub: '1', rol: 'admin' }İlk satır none saldırısı. İkinci satır bir sonraki bölümün konusu, ama kökleri aynı: ikisinde de header.alg hangi kodun çalışacağını seçiyor.
jsonwebtoken tarafında bu konuda Aralık 2022'de çıkan 9.0.0 sürümü bir dönüm noktası. 8.5.1, none token'ını bir anahtar verildiğinde reddediyordu ama anahtar null ya da undefined geldiğinde kabul ediyordu. Kulağa uç bir durum gibi geliyor. Anahtarı bir callback'ten ya da ortam değişkeninden okuyan kodda bilinmeyen bir kid ya da unutulmuş bir .env satırı anahtarı sessizce undefined yapar. 9.0.0 ile verify, algorithms listesinde açıkça none yoksa imzasız token'ı reddediyor.
2. Algoritmayı token'dan okumak
Bu daha ince bir hata. Sunucunuz token'ları RS256 ile doğruluyor: kimlik sağlayıcı private key ile imzalıyor, siz public key ile doğruluyorsunuz. Public key adı üstünde herkese açık. Saldırgan header'ı HS256 yapar ve imzayı HMAC-SHA256 ile, anahtar olarak da sizin public key'inizin PEM metnini vererek üretir. Algoritmayı header'dan seçen doğrulayıcı elindeki tek anahtarı, yani aynı PEM metnini HMAC secret'ı olarak kullanır ve imza tutar. Yukarıdaki çıktının ikinci satırında olan buydu.
RFC 8725 bu iki saldırıyı listesinin en başına koyar ve çözümü açık yazar. Kabul edilecek algoritmaları çağıran belirler, kütüphane bunların dışında hiçbir algoritmayı kullanmaz, her anahtar tek bir algoritmaya bağlanır ve bu bağ imza kontrolü sırasında denetlenir. Aynı iki saldırı token'ını jsonwebtoken 9.0.0'a verelim.
// 03-v9.jsconst crypto = require('crypto');const jwt = require('jsonwebtoken'); // 9.0.0const { b64 } = require('./02-naif'); const { publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });const pem = publicKey.export({ type: 'spki', format: 'pem' }); const none = `${b64({ alg: 'none', typ: 'JWT' })}.${b64({ sub: '1', rol: 'admin' })}.`;const h = b64({ alg: 'HS256', typ: 'JWT' });const p = b64({ sub: '1', rol: 'admin' });const hs = `${h}.${p}.${crypto.createHmac('sha256', pem).update(`${h}.${p}`).digest('base64url')}`; const dene = (ad, fn) => { try { console.log(ad, '->', fn()); } catch (e) { console.log(ad, '->', e.message); } }; dene('none, anahtarlı ', () => jwt.verify(none, pem));dene('none, anahtarsız ', () => jwt.verify(none, null));dene('HS256, alg listesi yok ', () => jwt.verify(hs, pem));dene('HS256, alg: [HS256,RS256]', () => jwt.verify(hs, pem, { algorithms: ['HS256', 'RS256'] }));dene('HS256, alg: [RS256] ', () => jwt.verify(hs, pem, { algorithms: ['RS256'] }));| Durum | 8.5.1 | 9.0.0 |
|---|---|---|
none, anahtar verilmiş | reddedildi | reddedildi |
none, anahtar null | kabul edildi | reddedildi |
HS256, algorithms verilmemiş | reddedildi | reddedildi |
HS256, algorithms: ['HS256', 'RS256'] | kabul edildi | reddedildi (anahtar türü uyuşmuyor) |
HS256, algorithms: ['RS256'] | reddedildi | reddedildi |
Dördüncü satır 9.0.0'ın asıl kazancı. Eskiden hem iç servislerin HMAC token'larını hem dış sağlayıcının RSA token'larını aynı middleware'den geçirmek için karışık bir liste yazan geliştirici, açığı kendi eliyle açıyordu. 9.0.0 artık anahtarın türüne bakıyor: HS ailesi için secret, RS, PS ve ES aileleri için public key istiyor. İmzalarken de 2048 bitten kısa RSA anahtarını reddediyor.
Uyarı
algorithms listesini her verify çağrısında açıkça verin ve listeye tek bir aile koyun. İki farklı token türünü doğrulamanız gerekiyorsa iki ayrı doğrulayıcı yazın, her birinin kendi anahtarı ve kendi listesi olsun.HS256 kullanıyorsanız bir ayrıntı daha var. HMAC secret'ı bir parola olamaz. Elinde tek bir geçerli token olan saldırgan, secret'ı çevrimdışı sözlük saldırısıyla deneyerek bulabilir. RFC 8725, ezberlenebilecek bir parolanın doğrudan HMAC anahtarı yapılmasını yasaklar. openssl rand -base64 32 ile üretilen 256 bitlik rastgele bir değer HS256 için yeterli.
3. kid değerine güvenmek
Birden çok anahtarınız olduğunda token'ın hangisiyle imzalandığını header'daki kid söyler. RFC 7515'e göre kid bir ipucudur ve yapısı tanımsızdır. Ne yazacağına token'ı üreten karar verir, token'ı saldırgan da üretebilir. Aşağıdaki kodda anahtarlar keys/ klasöründe dosya olarak duruyor ve doğrulayıcı kid'i dosya adı olarak kullanıyor.
// 04-kid.jsconst fs = require('fs');const path = require('path');const crypto = require('crypto');const { promisify } = require('util');const jwt = require('jsonwebtoken'); // 9.0.0const { b64 } = require('./02-naif');const verify = promisify(jwt.verify); // Yanlış: kid doğrudan dosya yoluna giriyorfunction naifAnahtar(header, cb) { fs.readFile(path.join(__dirname, 'keys', header.kid), cb);} // Doğru: kid yalnızca bilinen bir tablodaki anahtarı seçerconst ANAHTARLAR = new Map([['2023-06', fs.readFileSync(path.join(__dirname, 'keys', '2023-06'))]]);function guvenliAnahtar(header, cb) { const k = ANAHTARLAR.get(header.kid); if (!k) return cb(new Error(`bilinmeyen kid: ${String(header.kid).slice(0, 40)}`)); cb(null, k);} // Saldırgan /dev/null'u gösteriyor: boş dosya, boş HMAC anahtarıconst h = b64({ alg: 'HS256', typ: 'JWT', kid: '../../../../../../../../dev/null' });const p = b64({ sub: '1', rol: 'admin' });const s = crypto.createHmac('sha256', Buffer.alloc(0)).update(`${h}.${p}`).digest('base64url');const token = `${h}.${p}.${s}`; (async () => { for (const [ad, getKey] of [['naif', naifAnahtar], ['güvenli', guvenliAnahtar]]) { try { console.log(ad.padEnd(8), '->', await verify(token, getKey, { algorithms: ['HS256'] })); } catch (e) { console.log(ad.padEnd(8), '->', e.message); } }})();$ node 04-kid.jsnaif -> { sub: '1', rol: 'admin' }güvenli -> error in secret or public key callback: bilinmeyen kid: ../../../../../../../../dev/nullBu sefer kütüphane jsonwebtoken 9.0.0 ve token yine geçti. İmza kontrolü doğru çalıştı, sorun ona verilen anahtarda. /dev/null boş döner, boş bir HMAC anahtarıyla imzayı da herkes üretir. Aynı hata başka kılıklarda da gelir. kid bir SQL sorgusuna girerse ' UNION SELECT 'bildigim-sir gibi bir değerle anahtarı saldırgan seçer. Header'daki jku ya da x5u alanından anahtar adresi okuyup oraya istek atan kod, saldırganın sunduğu anahtarı kabul eder ve sunucunuzu iç ağa istek atan bir araca çevirir (SSRF). RFC 8725, kid üzerinden enjeksiyonu ve jku üzerinden SSRF'i aynı başlık altında ele alır: gelen claim'lere güvenmeyin.
Çözümde kid yalnızca önceden yüklenmiş bir sözlükte arama anahtarı olur. Map ya da JWKS fark etmez. Sözlükte karşılığı yoksa token reddedilir ve başka hiçbir yola sapılmaz. JWKS adresi de token'dan değil yapılandırmadan gelir.
JWKS önbelleği ve anahtar rotasyonu
Dış bir kimlik sağlayıcı kullanıyorsanız public key'ler bir JWKS (JSON Web Key Set) adresinden gelir. Her istekte bu seti indirmek hem yavaştır hem de sizin trafiğinizi sağlayıcıya yükler. Seti süresiz önbellekte tutmak ise sağlayıcı anahtar değiştirdiği anda bütün isteklerinizi reddettirir.
jose'nin createRemoteJWKSet fonksiyonu bu dengeyi hazır kurar. 4.14.4'teki varsayılanlar kaynak kodda şöyle: set 10 dakika (cacheMaxAge) önbellekte kalır, bilinmeyen bir kid gelince set yeniden indirilir ama son indirmeden sonraki 30 saniye (cooldownDuration) içinde indirilmez, istek de 5 saniyede (timeoutDuration) zaman aşımına düşer. Bekleme süresi, rastgele kid'lerle gelen birinin sunucunuzu sağlayıcıya istek yağdıran bir araca çevirmesini engeller. Aşağıdaki çıktı, birazdan kuracağımız doğrulayıcının yeni bir anahtarla karşılaştığı anı gösteriyor. Denemede JWKS'i kimlik sağlayıcıyı taklit eden küçük bir yerel HTTP sunucusu yayımlıyor, ikinci deneme saat 31 saniye ileri alınarak yapıldı.
$ node 06-jose.mjsyeni kid, hemen -> ERR_JWKS_NO_MATCHING_KEY no applicable key found in the JSON Web Key Setyeni kid, 31 sn sonra -> OK sub = 42JWKS isteği: 2Rotasyonun sırası bu davranıştan çıkar. Yeni anahtarı önce JWKS'e ekleyin, imzada kullanmaya en az bir bekleme süresi sonra başlayın. Eski anahtarı, onunla imzalanmış son token'ın süresi dolana kadar sette tutun. Bir anahtar sızdıysa onu setten çıkarmak da anında etki etmez. Doğrulayıcılar önbellek süresi boyunca eski seti kullanmaya devam eder, acil bir durumda bu 10 dakikayı hesaba katın.
4. Claim'leri yarım kontrol etmek
Doğru bir imza, token'ı imzalayanın yazdıklarının değişmediğini kanıtlar. Token'ın size, şu an ve bu iş için verilip verilmediği ise ayrı sorular. Bunları claim'ler cevaplar ve kütüphaneler beklediğinizden azını kendiliğinden kontrol eder.
// 05-claims.jsconst jwt = require('jsonwebtoken'); // 9.0.0const SECRET = require('crypto').randomBytes(32); const sonsuz = jwt.sign({ sub: '42' }, SECRET); // exp yokconst dene = (ad, fn) => { try { console.log(ad, '->', fn()); } catch (e) { console.log(ad, '->', e.message); } }; dene('exp yok, düz verify ', () => jwt.verify(sonsuz, SECRET, { algorithms: ['HS256'] }));dene('exp yok, maxAge: 15m ', () => jwt.verify(sonsuz, SECRET, { algorithms: ['HS256'], maxAge: '15m', clockTimestamp: Math.floor(Date.now() / 1000) + 3600 })); // bir saat sonrasını taklit et // Başka bir servis için basılmış tokenconst baska = jwt.sign({ sub: '42' }, SECRET, { audience: 'raporlama', issuer: 'https://auth.ornek.com', expiresIn: '5m' });dene('aud kontrolsüz ', () => jwt.verify(baska, SECRET, { algorithms: ['HS256'] }).aud);dene('aud: odeme ', () => jwt.verify(baska, SECRET, { algorithms: ['HS256'], audience: 'odeme' }));$ node 05-claims.jsexp yok, düz verify -> { sub: '42', iat: 1687900080 }exp yok, maxAge: 15m -> maxAge exceededaud kontrolsüz -> raporlamaaud: odeme -> jwt audience invalid. expected: odemeexp alanı olmayan token jsonwebtoken için süresiz geçerli. RFC 7519'da exp isteğe bağlı, kütüphane de onu ancak varsa kontrol ediyor. iss ve aud için durum daha da açık: seçeneği vermezseniz kütüphane hiç bakmıyor. aud kontrol edilmeyince raporlama servisi için üretilmiş bir token ödeme API'sinde de geçiyor. RFC 8725 buna farklı JWT türlerinin birbirine karışması der ve her tür için ayrı aud ile header'da açık bir typ önerir. Access token'lar için bu değer RFC 9068'de at+jwt olarak tanımlı.
| Claim | Sorduğu | jsonwebtoken 9.0.0 | jose 4.14.4 |
|---|---|---|---|
exp | Süresi doldu mu? | Yalnızca varsa kontrol eder | Varsa kontrol eder, requiredClaims: ['exp'] ile zorunlu olur |
nbf | Geçerlilik başladı mı? | Varsa kontrol eder | Varsa kontrol eder |
iat | Ne zaman üretildi? | maxAge verilirse yaşına bakar | maxTokenAge verilirse yaşına ve gelecekte olup olmadığına bakar |
iss | Kim üretti? | issuer verilirse | issuer verilirse |
aud | Kimin için üretildi? | audience verilirse | audience verilirse |
Doğrulama başarısız olduğunda dönülecek kod 401, token geçerli ama yetki yetmiyorsa 403. Bu ayrımı HTTP durum kodlarını anlattığım yazıda da bulabilirsiniz. İstemci 401'i görünce token yenilemeyi dener, 403'te denemez, kodları karıştırmak gereksiz yenileme döngüsü üretir.
Saat kayması ve clockTolerance
Token'ı üreten sunucuyla doğrulayan sunucunun saatleri birkaç saniye farklıysa yeni üretilmiş bir token nbf yüzünden reddedilir ya da süresi dolmak üzere olan bir token bir sunucuda geçip öbüründe kalır. RFC 7519 bunun için küçük bir pay bırakmaya izin verir ve bu payın genellikle birkaç dakikayı geçmediğini söyler. İki kütüphanede de clockTolerance saniye cinsinden. Ben 30 saniyeyi yeterli bulurum. Daha büyük bir paya ihtiyaç duyuyorsanız önce sunucuların NTP ayarına bakın.
Hepsi bir arada: jose ile doğrulayıcı
// 06-jose.mjs (doğrulayıcı kısmı)import { createRemoteJWKSet, jwtVerify } from 'jose'; // 4.14.4 const JWKS = createRemoteJWKSet(new URL(JWKS_URL)); // adres yapılandırmadan gelir, token'dan değil async function dogrula(token) { const { payload } = await jwtVerify(token, JWKS, { algorithms: ['RS256'], // listeyi sunucu belirler, token değil issuer: 'https://auth.ornek.com', audience: 'odeme-api', requiredClaims: ['exp', 'sub'], // exp'siz token reddedilsin maxTokenAge: '1h', // iat çok eskiyse reddet clockTolerance: 30, // saniye }); return payload;}$ node 06-jose.mjsgeçerli -> OK sub = 42exp yok -> ERR_JWT_CLAIM_VALIDATION_FAILED missing required "exp" claim20 sn önce dolmuş -> OK sub = 4290 sn önce dolmuş -> ERR_JWT_EXPIRED "exp" claim timestamp check failedbaşka aud -> ERR_JWT_CLAIM_VALIDATION_FAILED unexpected "aud" claim valuealg none -> ERR_JOSE_ALG_NOT_ALLOWED "alg" (Algorithm) Header Parameter not allowed20 saniye önce dolmuş token 30 saniyelik pay sayesinde geçti, 90 saniye önce dolmuş olan geçmedi. maxTokenAge verdiğiniz anda iat de zorunlu hâle gelir. iat taşımayan bir token, aud kontrolüne sıra gelmeden missing required "iat" claim hatasıyla reddedilir. Sağlayıcınızın iat koyduğundan emin değilseniz bu seçeneği açmadan önce bakın.
5. JWT'yi oturum gibi kullanmak
Buraya kadar her şeyi doğru yaptığınızı varsayalım. Kullanıcı çıkış yaptı, şifresini değiştirdi ya da hesabı kapatıldı. Elindeki access token'ın süresinin dolmasına 50 dakika var. Ne olur? Hiçbir şey. İmzalı token kendi kanıtını taşır ve sunucu ona veritabanına bakmadan inanır. Stateless olmanın bedeli bu: iptal için bir yere bakmanız gerekir, baktığınız anda da stateless olmaktan çıkarsınız.
JWT'nin en rahat çalıştığı yer servisler arası yetkilendirme. Bir API gateway kimliği doğrular, arkadaki REST servislerine imzalı bir iddia taşır, her servis veritabanına gitmeden karar verir. Tek alan adında çalışan klasik bir web uygulamasında ise sunucu tarafı oturum ile bir httpOnly cookie, iptal sorununu baştan çözer. JWT'yi yine de kullanacaksanız işleyen düzen şöyle:
- Access token 5 ila 15 dakika yaşasın. Geri alınamayan pencere bu kadar kalır.
- Refresh token JWT olmasın. Rastgele üretilmiş, veritabanında özeti tutulan opak bir değer olsun. İptal etmek bir satırı silmek demektir.
- Her yenilemede refresh token da değişsin. Kullanılmış bir refresh token yeniden gelirse biri onu kopyalamıştır, aynı zincirden üretilmiş bütün token'ları iptal edin.
- Anında iptal şartsa
jtideğerleriyle bir kara liste tutun. Listedeki kayıt yalnızca token'ınexpanına kadar yaşar, Redis'te TTL ile tutmak kolaydır.
localStorage mı, httpOnly cookie mi
Token'ı tarayıcıda nerede tuttuğunuz, XSS ile CSRF arasında bir seçimdir. localStorage'daki değeri sayfada çalışan her JavaScript okur. Tek bir XSS açığı token'ın saldırganın sunucusuna gitmesine yeter ve token süresi dolana kadar başka bir makineden kullanılır. httpOnly cookie'yi JavaScript okuyamaz. Ama tarayıcı onu her istekte kendiliğinden gönderdiği için CSRF kapısı açılır.
| Nerede | XSS olursa | CSRF | Bedeli |
|---|---|---|---|
| localStorage | Token çalınır, başka makineden kullanılır | Etkilenmez | En kolayı, en riskli |
| Bellek (JS değişkeni) | Sayfa açıkken yakalanabilir, kalıcı çalınması zordur | Etkilenmez | Sayfa yenilenince kaybolur, refresh ile geri alınır |
HttpOnly; Secure; SameSite cookie | Okunamaz, ama sekme açıkken saldırgan kullanıcı adına istek atabilir | SameSite ve CSRF token ile kapatılır | Sunucu tarafında ek iş |
httpOnly cookie XSS'i çözmez, yalnızca çalınan şeyin ömrünü sekmenin ömrüne indirir. Ben tarayıcı uygulamalarında access token'ı bellekte, refresh token'ı HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh bir cookie'de tutmayı tercih ederim. API çağrıları Authorization başlığıyla gittiği için CSRF'e açık değildir, refresh cookie'si de yalnızca tek bir endpoint'e gider.
Sonuç
Doğrulama kodunuza bakarken her satıra tek bir soru sorun: bu satır kararı kimden alıyor? alg, kid, jku, exp'in var olup olmadığı... Bunların hepsi token'ın içinden okunabilir ve hepsi saldırganın yazabileceği alanlar. Algoritma listesi, anahtar tablosu, JWKS adresi, zorunlu claim'ler ve tolerans yapılandırmada durur, token yalnızca bunlarla karşılaştırılır. Token'dan bir değer okuyup ona göre dallanan bir satır gördüğünüzde onu hata sayın. Aksi kanıtlanana kadar da öyle kalsın.
Sıkça Sorulan Sorular
- HS256 mı kullanmalıyım, RS256 mı?
- Token'ı üreten ve doğrulayan aynı servisse HS256 yeterli ve hızlıdır. Doğrulayan birden çok servis varsa asimetrik bir algoritma seçin, çünkü HS256'da doğrulayabilen her servis token da üretebilir. RS256'nın yanında ES256'ya da bakın: 2048 bitlik anahtarla RS256 imzası 256 bayt tutarken ES256 imzası 64 bayttır ve her istekte taşınan başlık küçülür.
- Kullanıcı şifresini değiştirince bütün cihazlardaki oturumları nasıl kapatırım?
- Kullanıcı kaydına bir sayaç ekleyin, örneğin
tokenVersion, ve bu değeri token'a da yazın. Şifre değişince sayacı bir artırın, doğrulamada token'daki değerle kayıttaki değeri karşılaştırın. Bu her istekte bir okuma demek, ama tek bir anahtar-değer okuması ve kısa süreli önbelleğe alınabilir. Aynı anda kullanıcının bütün refresh token'larını da silin. - Token'ı URL'de taşımak sorun mu?
- Evet. Query string'deki token sunucu loglarına, tarayıcı geçmişine ve
Refererbaşlığıyla başka sitelere düşer. Token'ıAuthorization: Bearerbaşlığında taşıyın. WebSocket gibi başlık eklemenin zor olduğu yerlerde bağlantıdan sonraki ilk mesajda gönderin ya da birkaç saniye geçerli, tek kullanımlık bir ticket kullanın. - `jsonwebtoken` 8'den 9'a geçmek mevcut kodu kırar mı?
- Kırabilir. 9.0.0 Node 12 ve üstünü istiyor, 2048 bitten kısa RSA anahtarıyla imzalamayı ve anahtar türüyle uyuşmayan algoritmaları reddediyor. Testlerde hız için 1024 bitlik anahtar üreten kod ilk patlayan yer olur. Geçiş sırasında
allowInsecureKeySizesveallowInvalidAsymmetricKeyTypesseçenekleri eski davranışı geri getirir. Bunları bir geçiş adımı olarak kullanıp kaldırın, kalıcı yapmayın.
Kaynakça
- Auth0. (2022). node-jsonwebtoken change log (Sürüm 9.0.0). GitHub. https://github.com/auth0/node-jsonwebtoken/blob/v9.0.0/CHANGELOG.md
- Jones, M., Bradley, J., ve Sakimura, N. (2015a). JSON Web Signature (JWS) (RFC 7515). Internet Engineering Task Force. https://doi.org/10.17487/RFC7515
- Jones, M., Bradley, J., ve Sakimura, N. (2015b). JSON Web Token (JWT) (RFC 7519). Internet Engineering Task Force. https://doi.org/10.17487/RFC7519
- McLean, T. (2015, 31 Mart). Critical vulnerabilities in JSON Web Token libraries. Auth0 Blog. https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/
- OWASP Foundation. (t.y.). JSON Web Token cheat sheet. OWASP Cheat Sheet Series. https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_Cheat_Sheet.html
- Sheffer, Y., Hardt, D., ve Jones, M. (2020). JSON Web Token Best Current Practices (RFC 8725). Internet Engineering Task Force. https://doi.org/10.17487/RFC8725

Mustafa Kürşad Başer
Kıdemli Yazılım Mühendisi
Karmaşık sorunlara zarif çözümler üretmekten keyif alan, tutkulu bir yazılım mühendisi. Kodlamanın ötesinde, teknoloji, sanat ve insan bilincinin kesişim noktalarını keşfetmekle derinden ilgileniyor.

