karawaci.kode

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

AspekJWT + Refresh TokenPure Session Redis
Revokasi access tokenTidak bisa instan (terima window 10-15 menit)Instan
Latency per requestRendah — tidak ada I/O untuk validasi token+1.5–3ms Redis lookup
Horizontal scalingStateless, mudahButuh Redis terpusat
Payload besarRisiko ada, perlu disiplinTidak relevan
Machine-to-machine authNaturalTidak tepat
Audit trail per-sesiLewat session ID di JWTAlami lewat session store
Revokasi massal per-tenantLewat refresh token DBNative di Redis
Kompleksitas implementasiLebih 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