Ed25519 JWT ile servisler arası kimlik doğrulama
Bir API gateway arkasındaki backend servislerine gelen isteklerin gerçekten gateway'den geldiğini nasıl doğrularsınız? Shared secret (HMAC) ile başlayan çoğu mimari, zamanla secret rotation kabusuna dönüşür. Servis sayısı arttıkça her servise aynı secret'ı dağıtmak, bir sızmada tüm zincirin tehlikeye girmesi anlamına gelir. Asimetrik imzalı, kısa ömürlü token'lar bu problemi kökünden çözer.
Neden Ed25519?
RSA-256 ile imzalanan JWT'ler yaygın olsa da Ed25519 (EdDSA ailesi) birkaç açıdan daha iyi bir tercih. RFC 8037 ile JOSE standardına girdi, RFC 8032 ile algoritma tanımı netleşti.
RSA-2048 imzası ~1500 mikrosaniye sürerken Ed25519 imzası aynı donanımda ~50-80 mikrosaniye civarında tamamlanır. Doğrulama da benzer oranda hızlı. Anahtar boyutu 32 byte (özel) + 32 byte (açık) ile RSA'nın 256+ byte'lık anahtarlarına kıyasla çok küçük.
Daha da önemlisi: Ed25519'un "misuse-resistant" tasarımı var. Zayıf rastgelelik kaynağı kullansanız bile nonce tekrarı yaşanmaz, çünkü nonce mesajın hash'inden türetilir. RSA-PKCS#1 v1.5 gibi padding oracle saldırılarına açık bir yüzey yok.
Alg confusion saldırısı ve savunma
JWT kütüphanelerinin en bilinen açığı "algorithm confusion" saldırısıdır. Saldırgan, token header'ındaki alg alanını HS256 olarak değiştirir ve açık anahtarı (public key) HMAC secret'ı olarak kullanır. Eğer doğrulama kodu header'daki alg değerine güvenip buna göre algoritma seçiyorsa, saldırgan geçerli bir imza üretebilir.
Savunma basit ama disiplin ister: doğrulama tarafında algoritma asla token'dan okunmaz, kod tarafında sabit olarak belirtilir.
// YANLIŞ: token'daki alg'a güvenmek
const payload = jwt.verify(token, publicKey); // alg otomatik seçilir
// DOĞRU: algoritmayı sabitlemek
const payload = jwt.verify(token, publicKey, { algorithms: ["EdDSA"] });Bu tek satırlık fark, alg confusion saldırısını tamamen engeller. algorithms dizisine yalnızca beklenen algoritmayı koyun. Birden fazla algoritma kabul etmek, saldırı yüzeyini tekrar açar.
Token tasarımı: 60 saniye ömür
Servisler arası iletişimde token'ın ömrü ne kadar kısa olursa, çalınma durumunda zarar penceresi o kadar daralır. 60 saniyelik exp değeri çoğu mimari için iyi bir denge noktası. Gateway her istekte yeni bir token üretir, backend doğrular ve işler.
Token payload'ı minimum bilgi taşımalı:
{
"iss": "api-gateway",
"sub": "user-42",
"aud": "billing-service",
"iat": 1752019200,
"exp": 1752019260,
"jti": "a1b2c3d4"
}aud alanı hangi servisin bu token'ı kabul etmesi gerektiğini belirtir. Backend tarafında aud kontrolü yapılmazsa, bir servise yönelik token başka bir serviste de geçerli olur. jti (JWT ID) replay saldırılarına karşı opsiyonel ama faydalı; 60 saniyelik pencerede bile aynı token'ın iki kez kullanılmasını engelleyebilirsiniz.
Anahtar üretimi
Node.js 16+ sürümlerinde Ed25519 anahtar çifti doğrudan crypto modülü ile üretilebilir:
import { generateKeyPairSync } from "node:crypto";
import { writeFileSync } from "node:fs";
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
writeFileSync(
"private.pem",
privateKey.export({ type: "pkcs8", format: "pem" })
);
writeFileSync(
"public.pem",
publicKey.export({ type: "spki", format: "pem" })
);Üretilen PEM dosyaları oldukça küçük. Özel anahtar yalnızca gateway'de bulunur, açık anahtar tüm backend servislerine dağıtılır. Açık anahtarın sızması bir güvenlik riski oluşturmaz; zaten doğrulama için herkese açık olması beklenir.
Gateway tarafı: token üretimi
jose kütüphanesi (v5+) EdDSA desteğini doğrudan sunar. Bu kütüphane Web Crypto API üzerine inşa edildiği için Node.js, Deno, Cloudflare Workers ve tarayıcıda çalışır.
import { SignJWT, importPKCS8 } from "jose";
import { readFileSync } from "node:fs";
const privateKeyPem = readFileSync("private.pem", "utf-8");
const privateKey = await importPKCS8(privateKeyPem, "EdDSA");
async function createServiceToken(
userId: string,
targetService: string
): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return new SignJWT({ sub: userId })
.setProtectedHeader({ alg: "EdDSA" })
.setIssuer("api-gateway")
.setAudience(targetService)
.setIssuedAt(now)
.setExpirationTime(now + 60)
.setJti(crypto.randomUUID())
.sign(privateKey);
}
// Kullanım
const token = await createServiceToken("user-42", "billing-service");Her istek için yeni token üretmenin maliyeti düşük. Ed25519 imzalama işlemi mikrosaniye mertebesinde tamamlandığından, gateway'e ölçülebilir bir yük bindirmez.
Backend tarafı: doğrulama
Backend servisi token'ı doğrularken dört kontrol yapmalı: algoritma sabitlemesi, exp kontrolü, iss kontrolü ve aud kontrolü.
import { jwtVerify, importSPKI } from "jose";
import { readFileSync } from "node:fs";
const publicKeyPem = readFileSync("public.pem", "utf-8");
const publicKey = await importSPKI(publicKeyPem, "EdDSA");
const EXPECTED_ISSUER = "api-gateway";
const SERVICE_NAME = "billing-service";
const CLOCK_TOLERANCE = 5; // saniye, sunucu saat farkı toleransı
async function verifyServiceToken(token: string) {
try {
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ["EdDSA"],
issuer: EXPECTED_ISSUER,
audience: SERVICE_NAME,
clockTolerance: CLOCK_TOLERANCE,
});
return payload;
} catch (err) {
// Token geçersiz, süresi dolmuş veya yanlış servise yönlendirilmiş
return null;
}
}clockTolerance parametresi dikkat gerektirir. Dağıtık sistemlerde sunucu saatleri birkaç saniye kayabilir. 5 saniyelik tolerans makul bir değer. Daha yüksek tolerans, token'ın etkin ömrünü uzatır; bu yüzden NTP senkronizasyonu sağlamak toleransı düşük tutmanın ön koşulu.
Hono.js middleware örneği
Backend Hono.js gibi bir framework kullanıyorsa, doğrulamayı middleware olarak yazmak temiz bir ayrım sağlar:
import { Hono } from "hono";
import { jwtVerify, importSPKI } from "jose";
const app = new Hono();
const publicKey = await importSPKI(
process.env.GATEWAY_PUBLIC_KEY!,
"EdDSA"
);
app.use("/api/*", async (c, next) => {
const authHeader = c.req.header("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return c.json({ error: "missing token" }, 401);
}
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ["EdDSA"],
issuer: "api-gateway",
audience: "billing-service",
clockTolerance: 5,
});
c.set("userId", payload.sub);
await next();
} catch {
return c.json({ error: "invalid token" }, 401);
}
});
app.get("/api/invoices", (c) => {
const userId = c.get("userId");
return c.json({ userId, invoices: [] });
});Anahtar rotasyonu
Ed25519 anahtarını rotasyonla değiştirmek, HMAC secret rotasyonundan çok daha kolay. Yeni anahtar çifti üretilir, gateway yeni özel anahtarla imzalamaya başlar, backend'lere yeni açık anahtar dağıtılır. Geçiş süresinde backend'ler hem eski hem yeni açık anahtarı kabul edebilir:
import { jwtVerify, importSPKI, FlattenedJWSInput, JWSHeaderParameters, GetKeyFunction } from "jose";
const keys = [
await importSPKI(process.env.GATEWAY_PUBLIC_KEY_CURRENT!, "EdDSA"),
await importSPKI(process.env.GATEWAY_PUBLIC_KEY_PREVIOUS!, "EdDSA"),
];
async function verifyWithRotation(token: string) {
for (const key of keys) {
try {
const { payload } = await jwtVerify(token, key, {
algorithms: ["EdDSA"],
issuer: "api-gateway",
audience: "billing-service",
});
return payload;
} catch {
continue;
}
}
return null;
}Daha olgun bir yaklaşım, JWKS (JSON Web Key Set) endpoint'i sunmak. Gateway bir /.well-known/jwks.json endpoint'i üzerinden mevcut açık anahtarları yayınlar, backend'ler bu endpoint'i periyodik olarak çeker. jose kütüphanesinin createRemoteJWKSet fonksiyonu bunu doğrudan destekler. Ancak servisler arası iletişimde bu endpoint'in kendisi de kimlik doğrulama gerektirir, dolayısıyla başlangıçta statik anahtar dağıtımı daha az karmaşık.
Replay koruması
60 saniyelik ömür replay penceresini daraltır ama sıfırlamaz. jti alanı ile birlikte backend tarafında kısa ömürlü bir "görülmüş token" cache'i tutmak tam koruma sağlar:
// Basit in-memory replay koruması
const seenTokens = new Map<string, number>();
// Her 30 saniyede süresi dolmuş girişleri temizle
setInterval(() => {
const now = Math.floor(Date.now() / 1000);
for (const [jti, exp] of seenTokens) {
if (exp < now) seenTokens.delete(jti);
}
}, 30_000);
function checkReplay(jti: string, exp: number): boolean {
if (seenTokens.has(jti)) return false; // replay
seenTokens.set(jti, exp);
return true;
}Tek instance'lı servislerde Map yeterli. Birden fazla instance varsa Redis gibi paylaşımlı bir store gerekir, ama 60 saniyelik TTL ile bu store'un boyutu ihmal edilebilir kalır.
Sık yapılan hatalar
Token'ı HTTP header yerine query string'e koymak, URL'nin log dosyalarında, proxy cache'lerinde ve tarayıcı geçmişinde görünmesine neden olur. Servisler arası iletişimde bile Authorization: Bearer <token> header'ı standart yol.
Bir diğer hata: token payload'ına hassas veri koymak. JWT imzalıdır ama şifreli değildir. Base64 decode ile payload okunabilir. Kullanıcı ID'si veya servis adı gibi metadata yeterli; şifre hash'i, email adresi gibi PII bilgiler token'da bulunmamalı.
Üçüncü yaygın hata: exp kontrolünü "kendi kodumla yapıyorum" diyerek kütüphanenin yerleşik kontrolünü devre dışı bırakmak. jose ve benzeri kütüphaneler exp doğrulamasını varsayılan olarak yapar. Bu kontrolü atlamak, süresi dolmuş token'ların kabul edilmesine yol açar.
Ne zaman bu desen, ne zaman mTLS?
Ed25519 JWT deseni, uygulama katmanında kimlik taşır. Gateway, token payload'ına kullanıcı bilgisi, hedef servis adı gibi bağlam ekleyebilir. mTLS ise transport katmanında çalışır; bağlantının iki ucunun kimliğini doğrular ama istek bazında bağlam taşımaz.
İkisi birbirinin alternatifi değil, tamamlayıcısı. Cloudflare Workers + D1 gibi edge ortamlarında mTLS yapılandırması her zaman mümkün olmayabilir; bu durumda JWT tek başına yeterli bir katman sunar. Kubernetes gibi kontrollü ortamlarda mTLS (service mesh üzerinden) + JWT kombinasyonu hem transport hem uygulama katmanında güvenlik sağlar.
60 saniyelik Ed25519 JWT, düşük maliyetli ve uygulanması kolay bir desen. Anahtarı üretin, gateway'de imzalayın, backend'de doğrulayın, algoritmayı sabitleyin. Bu dört adımı doğru yaparsanız, servisler arası kimlik doğrulama problemi büyük ölçüde çözülmüş olur.