2026-08-27 · 10 min
JWT vs Session Token untuk SaaS Multi-Tenant: Trade-off Keamanan dan Operasional
Setiap kali memulai project SaaS baru, debat “JWT atau session?” muncul lagi. Di klien Jakarta tahun lalu — platform B2B dengan ratusan tenant berbeda, tiap tenant punya user sendiri, permission sendiri, dan kadang region data sendiri — saya akhirnya memutuskan untuk tidak memilih salah satu secara murni, dan tulisan ini menjelaskan kenapa.
Kenapa masalah ini lebih kompleks di multi-tenant
Di aplikasi single-tenant, pilihan antara JWT dan session lebih straightforward. Tapi di SaaS multi-tenant, ada lapisan kompleksitas tambahan:
- Konteks tenant harus ikut di setiap request — setiap operasi database, setiap otorisasi, setiap audit log butuh tahu request ini berasal dari tenant mana.
- Revokasi per-tenant harus bisa dilakukan segera — kalau sebuah tenant melaporkan akun compromised, atau Anda harus suspend tenant karena non-pembayaran, semua sesi aktif tenant itu harus bisa dimatikan sekarang.
- Permission dan feature flag berbeda antar tenant — tenant di tier “Enterprise” punya akses fitur yang tidak ada di tier “Starter”, dan ini bisa berubah kapan saja.
- Regulatory compliance berbeda — untuk klien di sektor keuangan, ada audit trail yang harus bisa menghubungkan setiap action ke sesi yang spesifik.
JWT yang murni stateless tidak dirancang untuk semua constraint ini. Session server-side punya masalah tersendiri di arsitektur yang terdistribusi. Keduanya harus dikompromikan.
Anatomi JWT di konteks multi-tenant
JWT terdiri dari tiga bagian: header (algoritma), payload (klaim), dan signature. Payload-lah yang sering jadi masalah di multi-tenant.
Setup payload yang naif terlihat seperti ini:
// Payload JWT yang terlalu gemuk — jangan lakukan ini
interface JWTPayload {
sub: string; // user ID
tenantId: string;
tenantSlug: string;
email: string;
displayName: string;
role: string;
permissions: string[]; // bisa 20-50 item
featureFlags: Record<string, boolean>; // bertambah terus
plan: string;
iat: number;
exp: number;
}
Payload ini bisa dengan mudah menembus 1–2KB setelah Base64 encode. Di aplikasi dengan puluhan request per halaman, ini overhead nyata yang dikirim di setiap Authorization header.
Payload yang saya pakai di production sekarang:
// Payload minimal — identifier saja
interface AccessTokenPayload {
sub: string; // user ID
tid: string; // tenant ID
sid: string; // session ID — untuk tracing
rol: string; // role utama: 'owner' | 'admin' | 'member'
iat: number;
exp: number; // 10–15 menit
}
Permission spesifik dan feature flags tidak masuk token. Mereka di-fetch on-demand dengan cache pendek:
// permission-cache.ts
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
const LOCAL_TTL_MS = 30_000; // 30 detik in-memory
const localCache = new Map<string, { data: string[]; expiresAt: number }>();
export async function getPermissions(
userId: string,
tenantId: string,
): Promise<string[]> {
const cacheKey = `perm:${tenantId}:${userId}`;
// Layer 1: in-memory cache (hindari Redis hop berulang)
const cached = localCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.data;
}
// Layer 2: Redis
const fromRedis = await redis.get(cacheKey);
if (fromRedis) {
const data = JSON.parse(fromRedis) as string[];
localCache.set(cacheKey, { data, expiresAt: Date.now() + LOCAL_TTL_MS });
return data;
}
// Layer 3: database — fetch dan cache
const perms = await db.query.userPermissions.findMany({
where: and(
eq(userPermissions.userId, userId),
eq(userPermissions.tenantId, tenantId),
),
columns: { permission: true },
});
const permList = perms.map((p) => p.permission);
await redis.set(cacheKey, JSON.stringify(permList), 'EX', 120); // 2 menit
localCache.set(cacheKey, { data: permList, expiresAt: Date.now() + LOCAL_TTL_MS });
return permList;
}
Invalidasi cache permission dipicu eksplisit saat ada perubahan role:
export async function invalidatePermissionCache(
userId: string,
tenantId: string,
): Promise<void> {
const cacheKey = `perm:${tenantId}:${userId}`;
await redis.del(cacheKey);
localCache.delete(cacheKey);
}
Masalah revokasi dan solusi refresh token
Ini inti dari keterbatasan JWT: kalau access token expired dalam 15 menit, Anda bisa “toleransi” tidak punya revokasi instan untuk access token. Tapi refresh token — yang berumur 7–30 hari — harus bisa dicabut.
Tabel refresh token di Postgres:
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE, -- simpan hash, bukan token asli
session_id TEXT NOT NULL,
user_agent TEXT,
ip_address INET,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
revoke_reason TEXT
);
CREATE INDEX idx_refresh_tokens_user_tenant
ON refresh_tokens(user_id, tenant_id)
WHERE revoked_at IS NULL;
Endpoint refresh token melakukan validasi berlapis:
// POST /auth/refresh
export async function handleRefresh(req: Request, res: Response) {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ code: 'NO_REFRESH_TOKEN' });
}
const tokenHash = hashToken(refreshToken); // sha256
const stored = await db.query.refreshTokens.findFirst({
where: and(
eq(refreshTokens.tokenHash, tokenHash),
isNull(refreshTokens.revokedAt),
gt(refreshTokens.expiresAt, new Date()),
),
});
if (!stored) {
// Token tidak ada, sudah expired, atau sudah di-revoke
// Clear cookie sekalian agar browser tidak terus mencoba
res.clearCookie('refreshToken');
return res.status(401).json({ code: 'INVALID_REFRESH_TOKEN' });
}
// Cek apakah tenant masih aktif
const tenant = await getTenantById(stored.tenantId);
if (tenant.status !== 'active') {
return res.status(403).json({ code: 'TENANT_SUSPENDED' });
}
// Rotate refresh token — revoke yang lama, terbitkan yang baru
await db.transaction(async (tx) => {
await tx.update(refreshTokens)
.set({ revokedAt: new Date(), revokeReason: 'rotated' })
.where(eq(refreshTokens.id, stored.id));
const newRefreshToken = generateSecureToken();
await tx.insert(refreshTokens).values({
userId: stored.userId,
tenantId: stored.tenantId,
tokenHash: hashToken(newRefreshToken),
sessionId: stored.sessionId,
userAgent: req.headers['user-agent'],
ipAddress: req.ip,
expiresAt: addDays(new Date(), 30),
});
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
});
const newAccessToken = signAccessToken({
sub: stored.userId,
tid: stored.tenantId,
sid: stored.sessionId,
rol: stored.role,
});
return res.json({ accessToken: newAccessToken });
}
Revokasi semua sesi satu tenant (untuk suspend atau breach):
export async function revokeAllTenantSessions(
tenantId: string,
reason: string,
): Promise<number> {
const result = await db.update(refreshTokens)
.set({ revokedAt: new Date(), revokeReason: reason })
.where(
and(
eq(refreshTokens.tenantId, tenantId),
isNull(refreshTokens.revokedAt),
),
);
// Invalidasi semua cache permission tenant ini juga
await redis.eval(
`local keys = redis.call('keys', ARGV[1]) for _, key in ipairs(keys) do redis.call('del', key) end return #keys`,
0,
`perm:${tenantId}:*`,
);
return result.rowCount ?? 0;
}
Pure session server-side: kapan masih masuk akal
Untuk klien dengan kebutuhan compliance tinggi — misalnya fintech yang harus memenuhi POJK tentang perlindungan data — saya masih merekomendasikan session server-side murni, disimpan di Redis Cluster.
// session-store.ts dengan Redis
import { createClient } from 'redis';
const SESSION_TTL = 8 * 60 * 60; // 8 jam
export async function createSession(
userId: string,
tenantId: string,
metadata: SessionMetadata,
): Promise<string> {
const sessionId = generateSecureToken(32);
const sessionKey = `session:${sessionId}`;
await redis.setEx(
sessionKey,
SESSION_TTL,
JSON.stringify({
userId,
tenantId,
createdAt: Date.now(),
lastActiveAt: Date.now(),
...metadata,
}),
);
// Index per tenant untuk keperluan revokasi massal
await redis.sAdd(`tenant_sessions:${tenantId}`, sessionId);
await redis.expire(`tenant_sessions:${tenantId}`, SESSION_TTL + 300);
return sessionId;
}
export async function revokeAllTenantSessionsRedis(tenantId: string): Promise<void> {
const sessionIds = await redis.sMembers(`tenant_sessions:${tenantId}`);
if (sessionIds.length > 0) {
const pipeline = redis.multi();
for (const sid of sessionIds) {
pipeline.del(`session:${sid}`);
}
pipeline.del(`tenant_sessions:${tenantId}`);
await pipeline.exec();
}
}
Konsekuensinya: setiap request butuh Redis lookup, yang di production saya ukur sekitar 1.5–2.5ms dari server di Jakarta ke Redis di region yang sama. Tidak signifikan untuk API endpoint biasa, tapi perlu dipertimbangkan untuk endpoint high-frequency seperti polling atau WebSocket handshake.
Trade-off yang jujur
| Aspek | JWT + Refresh Token | Pure Session Redis |
|---|---|---|
| Revokasi access token | Tidak bisa instan (terima window 10-15 menit) | Instan |
| Latency per request | Rendah — tidak ada I/O untuk validasi token | +1.5–3ms Redis lookup |
| Horizontal scaling | Stateless, mudah | Butuh Redis terpusat |
| Payload besar | Risiko ada, perlu disiplin | Tidak relevan |
| Machine-to-machine auth | Natural | Tidak tepat |
| Audit trail per-sesi | Lewat session ID di JWT | Alami lewat session store |
| Revokasi massal per-tenant | Lewat refresh token DB | Native di Redis |
| Kompleksitas implementasi | Lebih tinggi (dua token, rotasi) | Lebih rendah |
Rekomendasi saya
Untuk SaaS multi-tenant 2026, default saya adalah hybrid: short-lived JWT access token (10–15 menit) plus refresh token yang disimpan di database per tenant.
Geser ke pure session Redis kalau: aplikasi di domain regulated (perbankan, kesehatan) yang butuh revokasi instan, atau tim belum punya pengalaman solid mengelola JWT secret rotation dan rotation strategy.
Geser ke pure JWT tanpa refresh token hanya untuk: service internal machine-to-machine di belakang VPN, atau edge function yang benar-benar tidak bisa menyentuh stateful store.
Satu hal yang tidak bisa dikompromikan terlepas dari pilihan: jangan simpan JWT di localStorage. httpOnly cookie adalah minimum. Dan untuk multi-tenant, selalu validasi silang tenant ID di token dengan resource yang diakses — bukan cuma percaya klaim di payload.
Penutup
Perdebatan JWT vs session sering berakhir di argumen teknis yang mengabaikan kebutuhan operasional sebenarnya. Di production, kemampuan merevoke seluruh sesi satu tenant dalam satu perintah jauh lebih berharga dari elegansnya stateless auth. Hybrid approach bukan kompromi lemah — ini pilihan sadar yang mengambil yang terbaik dari dua model sambil menerima trade-off masing-masing dengan mata terbuka.
Ditulis oleh Reza Pradipta