"Auth yang buruk bukan yang tidak ada — tapi yang terlihat ada tapi mudah dibobol."
Tentang Artikel Ini
Auth adalah salah satu bagian paling kritis di backend — dan salah satu yang paling sering diimplementasikan dengan setengah-setengah. Tutorial di internet mengajarkan cara membuat JWT, tapi tidak mengajarkan cara mengelolanya dengan benar: bagaimana token dicabut, bagaimana refresh token dirotasi, bagaimana mendeteksi token yang dicuri.
Artikel ini mengisi gap itu. Kita akan membangun sistem auth yang benar-benar production-ready — dari schema database, register dan login, access + refresh token pattern, revocation, RBAC, sampai OAuth2 dengan Google dan GitHub.
Prasyarat: Sudah baca artikel #02 (REST API dengan Node.js & Express) dan #03 (Desain Database). Familiar dengan Express middleware dan PostgreSQL.
Daftar Isi
- Fondasi: Bagaimana JWT Bekerja
- Database Schema untuk Auth
- Register & Login
- Access Token & Refresh Token Pattern
- Token Revocation & Logout
- Keamanan JWT
- Role-Based Access Control (RBAC)
- OAuth2: Login dengan Google & GitHub
- Multi-Device Session Management
Bab 1: Fondasi: Bagaimana JWT Bekerja
Sebelum masuk ke database dan kode, saya selalu pastikan dulu paham betul apa yang sebenarnya ada di dalam JWT — kebanyakan bug auth yang saya temui justru berakar dari kesalahpahaman di bagian paling dasar ini, bukan dari kode yang rumit.
Anatomi JWT
Buka JWT mana pun dan kamu akan selalu menemukan pola yang sama: string panjang dengan tiga bagian dipisahkan titik. Begitu polanya kelihatan, seumur hidup kamu akan langsung mengenalinya di mana pun muncul:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Tiga bagian itu adalah:
[Header].[Payload].[Signature]
Header — metadata tentang token:
{
"alg": "HS256",
"typ": "JWT"
}Payload — data yang dibawa token (disebut claims):
{
"sub": "user-id-123",
"email": "user@email.com",
"role": "admin",
"iat": 1700000000,
"exp": 1700003600
}Signature — jaminan integritas:
HMACSHA256(
base64url(header) + "." + base64url(payload),
secret
)
Hal Paling Penting yang Harus Dipahami
Ini poin yang paling sering disalahpahami orang yang baru kenal JWT, jadi saya tegaskan di awal: JWT tidak dienkripsi — hanya di-sign. Artinya siapapun bisa membaca isi payload, tapi tidak bisa memalsukan-nya tanpa tahu secret key.
// Coba decode di jwt.io atau:
const [header, payload] = token.split('.');
console.log(JSON.parse(atob(payload)));
// Kamu bisa baca isinya tanpa secret!Implikasi langsung:
- Jangan simpan data sensitif di payload (password, nomor kartu, dsb)
- Payload hanya berisi data yang aman dibaca, seperti user ID, role, email
Standard Claims
Beberapa nama field di payload sudah punya makna baku yang dipahami hampir semua library JWT — pakai ini dulu sebelum menambahkan claim custom milikmu sendiri:
| Claim | Nama | Fungsi |
|---|---|---|
sub | Subject | ID unik user/entity yang token ini mewakili |
iss | Issuer | Siapa yang menerbitkan token (nama service/domain) |
exp | Expiration | Unix timestamp kapan token expired |
iat | Issued At | Unix timestamp kapan token dibuat |
jti | JWT ID | ID unik token ini — digunakan untuk blacklisting |
HS256 vs RS256
Di titik ini kamu perlu memutuskan satu hal: siapa saja yang boleh tahu cara memverifikasi token. Jawabannya yang menentukan algoritma mana yang dipakai.
HS256 (HMAC SHA-256) — symmetric:
- Satu secret key yang sama digunakan untuk sign dan verify
- Cocok untuk aplikasi monolith atau single service
- Masalah: semua service yang perlu verify token harus tahu secret key
RS256 (RSA SHA-256) — asymmetric:
- Private key untuk sign (hanya auth service yang tahu)
- Public key untuk verify (bisa dibagikan ke semua service)
- Cocok untuk microservices — service lain hanya butuh public key
- Lebih aman tapi setup lebih kompleks
// HS256 — satu secret
jwt.sign(payload, process.env.JWT_SECRET, { algorithm: 'HS256' });
jwt.verify(token, process.env.JWT_SECRET);
// RS256 — key pair
jwt.sign(payload, privateKey, { algorithm: 'RS256' });
jwt.verify(token, publicKey); // public key bisa di-shareRekomendasi: Mulai dengan HS256. Migrasi ke RS256 saat kamu membutuhkan multiple service yang perlu verify token secara independen.
Bab 2: Database Schema untuk Auth
Auth yang solid dimulai dari schema yang tepat, bukan dari library JWT yang dipilih. Sebelum menulis satu baris service pun, mari siapkan dulu empat tabel yang akan jadi tulang punggung sistem ini.
Tabel users
Semua bermula dari tabel ini — tempat identitas user disimpan, lengkap dengan password_hash yang sengaja dibuat nullable karena tidak semua user daftar lewat password (ada juga yang lewat OAuth, dibahas di Bab 8):
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255), -- NULL jika hanya pakai OAuth
name VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'user',
is_active BOOLEAN NOT NULL DEFAULT true,
email_verified BOOLEAN NOT NULL DEFAULT false,
email_verified_at TIMESTAMPTZ,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_role ON users(role);Tabel refresh_tokens
Jangan simpan refresh token sebagai plain text. Simpan hash-nya — jika database bocor, attacker tidak langsung punya token yang valid.
CREATE TABLE refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE, -- SHA-256 hash dari token
device_name VARCHAR(255), -- "Chrome on MacOS"
device_ip INET,
user_agent TEXT,
expires_at TIMESTAMPTZ NOT NULL,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ, -- NULL = masih valid
revoked_reason VARCHAR(100), -- 'logout', 'password_change', 'suspicious'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);
CREATE INDEX idx_refresh_tokens_active
ON refresh_tokens(user_id, expires_at)
WHERE revoked_at IS NULL;Tabel oauth_accounts
Satu user bisa punya beberapa akun OAuth (Google + GitHub sekaligus):
CREATE TABLE oauth_accounts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL, -- 'google', 'github'
provider_id VARCHAR(255) NOT NULL, -- ID dari provider
access_token TEXT, -- token dari provider (encrypt ini!)
refresh_token TEXT,
token_expires_at TIMESTAMPTZ,
profile_data JSONB, -- raw data dari provider
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(provider, provider_id) -- satu provider ID hanya untuk satu user
);
CREATE INDEX idx_oauth_accounts_user ON oauth_accounts(user_id);
CREATE INDEX idx_oauth_accounts_provider ON oauth_accounts(provider, provider_id);Tabel email_verifications
Tabel kecil terakhir untuk fondasi ini — menyimpan token verifikasi email yang nanti dikirim lewat link, dengan used_at supaya token yang sudah dipakai tidak bisa dipakai ulang:
CREATE TABLE email_verifications (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Bab 3: Register & Login
Schema sudah siap. Sekarang waktunya menyambungkannya ke alur yang paling sering dipakai user: daftar akun baru, lalu login.
Setup Dependencies
Sebelum menulis service-nya, pasang dulu library yang akan sering kita pakai sepanjang artikel ini — bcrypt untuk hashing password, jsonwebtoken untuk JWT, dan crypto bawaan Node untuk token acak:
npm install bcrypt jsonwebtoken crypto-js
npm install --save-dev @types/bcrypt @types/jsonwebtokenHelper: Token Generation
Semua fungsi generate dan verify token saya kumpulkan di satu file utility supaya tidak berserakan di banyak tempat — begini isinya:
// src/utils/token.js
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { config } from '../config/index.js';
// Generate access token — short-lived, stateless
export function generateAccessToken(user) {
return jwt.sign(
{
sub: user.id.toString(),
email: user.email,
role: user.role,
},
config.jwt.accessSecret,
{
expiresIn: config.jwt.accessExpiresIn, // '15m'
algorithm: 'HS256',
issuer: 'backendlabs-api',
}
);
}
// Generate refresh token — long-lived, disimpan di database
export function generateRefreshToken() {
// Cryptographically secure random string
const token = crypto.randomBytes(40).toString('hex');
// Hash untuk disimpan di DB — plain token dikirim ke client
const hash = crypto.createHash('sha256').update(token).digest('hex');
return { token, hash };
}
// Verify access token
export function verifyAccessToken(token) {
return jwt.verify(token, config.jwt.accessSecret, {
algorithms: ['HS256'], // SELALU explicit — mencegah alg:none attack
issuer: 'backendlabs-api',
});
}Register
Bagian register ini kelihatan sederhana, tapi ada dua detail yang sengaja saya jaga: password di-hash sebelum disimpan, dan pesan error yang tidak membocorkan apakah email tertentu sudah terdaftar atau belum. Berikut service-nya:
// src/services/auth.service.js
import bcrypt from 'bcrypt';
import pool from '../db/pool.js';
import { generateAccessToken, generateRefreshToken } from '../utils/token.js';
const BCRYPT_ROUNDS = 12; // 10-12 adalah sweet spot security vs performance
export const AuthService = {
async register({ name, email, password }) {
// Cek apakah email sudah dipakai
const existing = await pool.query(
'SELECT id FROM users WHERE email = $1',
[email]
);
if (existing.rows.length > 0) {
throw Object.assign(new Error('Email sudah terdaftar'), { statusCode: 409 });
}
// Hash password — jangan pernah simpan plain text
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
// Buat user
const result = await pool.query(
`INSERT INTO users (name, email, password_hash)
VALUES ($1, $2, $3)
RETURNING id, name, email, role, created_at`,
[name, email, passwordHash]
);
const user = result.rows[0];
// Issue tokens
const accessToken = generateAccessToken(user);
const { token: refreshToken, hash: refreshTokenHash } = generateRefreshToken();
// Simpan refresh token ke DB
await pool.query(
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '7 days')`,
[user.id, refreshTokenHash]
);
return { user, accessToken, refreshToken };
},
async login({ email, password, deviceName, deviceIp, userAgent }) {
// Ambil user — termasuk password_hash
const result = await pool.query(
'SELECT id, name, email, role, password_hash, is_active FROM users WHERE email = $1',
[email]
);
const user = result.rows[0];
// Pesan error yang sama untuk "user tidak ada" dan "password salah"
// Jangan bocorkan informasi apakah email terdaftar atau tidak
const genericError = Object.assign(
new Error('Email atau password tidak valid'),
{ statusCode: 401 }
);
if (!user || !user.password_hash) throw genericError;
if (!user.is_active) {
throw Object.assign(new Error('Akun ini telah dinonaktifkan'), { statusCode: 403 });
}
const passwordMatch = await bcrypt.compare(password, user.password_hash);
if (!passwordMatch) throw genericError;
// Update last login
await pool.query(
'UPDATE users SET last_login_at = NOW() WHERE id = $1',
[user.id]
);
// Issue tokens
const accessToken = generateAccessToken(user);
const { token: refreshToken, hash: refreshTokenHash } = generateRefreshToken();
await pool.query(
`INSERT INTO refresh_tokens
(user_id, token_hash, device_name, device_ip, user_agent, expires_at)
VALUES ($1, $2, $3, $4, $5, NOW() + INTERVAL '7 days')`,
[user.id, refreshTokenHash, deviceName, deviceIp, userAgent]
);
// Jangan kembalikan password_hash!
const { password_hash, ...safeUser } = user;
return { user: safeUser, accessToken, refreshToken };
},
};Controller & Route
Service di atas baru berguna kalau ada yang memanggilnya. Controller berikut menjembatani request HTTP ke AuthService, sekaligus menunjukkan dua cara umum mengirim refresh token ke client:
// src/controllers/auth.controller.js
import { AuthService } from '../services/auth.service.js';
export async function register(req, res, next) {
try {
const { name, email, password } = req.body;
const result = await AuthService.register({ name, email, password });
// Opsi 1: kirim token di body (lebih mudah untuk mobile/SPA)
res.status(201).json({
success: true,
data: {
user: result.user,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
},
});
// Opsi 2: kirim refresh token via httpOnly cookie (lebih aman dari XSS)
// res.cookie('refresh_token', result.refreshToken, {
// httpOnly: true,
// secure: config.isProd,
// sameSite: 'strict',
// maxAge: 7 * 24 * 60 * 60 * 1000,
// });
// res.status(201).json({ success: true, data: { user: result.user, accessToken: result.accessToken } });
} catch (error) {
next(error);
}
}
export async function login(req, res, next) {
try {
const { email, password } = req.body;
const deviceName = req.headers['x-device-name'] || 'Unknown device';
const deviceIp = req.ip;
const userAgent = req.headers['user-agent'];
const result = await AuthService.login({ email, password, deviceName, deviceIp, userAgent });
res.json({
success: true,
data: {
user: result.user,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
},
});
} catch (error) {
next(error);
}
}Bab 4: Access Token & Refresh Token Pattern
Kenapa Dua Token?
Ini pertanyaan yang paling sering saya dapat dari orang yang baru pertama kali pegang JWT — kenapa tidak satu token panjang saja, biar simpel? Jawabannya soal trade-off: satu token cepat diverifikasi tapi susah dicabut, satu lagi gampang dicabut tapi lebih lambat karena harus cek database. Dua token dipakai justru supaya kita dapat keduanya sekaligus.
Access token — stateless, cepat diverifikasi:
- Tidak perlu database lookup setiap request
- Tapi tidak bisa dicabut sebelum expired → harus short-lived (15 menit)
Refresh token — stateful, disimpan di database:
- Bisa dicabut kapanpun (logout, password change, suspicious activity)
- Long-lived (7 hari) karena hanya dipakai untuk minta access token baru
- Tidak dikirim ke setiap API endpoint — hanya ke
/auth/refresh
[Client] [Server]
| |
|-- POST /auth/login ------------->|
|<-- accessToken (15m) ------------|
| refreshToken (7d) |
| |
|-- GET /api/v1/products --------->| ← pakai accessToken
|<-- 200 OK ----------------------|
| |
| (15 menit berlalu) |
| |
|-- GET /api/v1/products --------->|
|<-- 401 Unauthorized ------------|
| |
|-- POST /auth/refresh ----------->| ← pakai refreshToken
|<-- accessToken baru (15m) ------|
| refreshToken baru (7d) | ← token lama direvoke!
| |
|-- GET /api/v1/products --------->| ← pakai accessToken baru
|<-- 200 OK ----------------------|
Refresh Token Rotation
Praktik yang saya pegang ketat di semua project: setiap kali refresh token dipakai, langsung terbitkan yang baru dan revoke yang lama. Ini mencegah refresh token yang dicuri dipakai berkali-kali diam-diam:
// src/services/auth.service.js (lanjutan)
export const AuthService = {
// ...
async refreshTokens(oldRefreshToken) {
const crypto = await import('crypto');
const tokenHash = crypto
.createHash('sha256')
.update(oldRefreshToken)
.digest('hex');
// Cari refresh token di database
const result = await pool.query(
`SELECT rt.*, u.id as user_id, u.email, u.role, u.is_active
FROM refresh_tokens rt
JOIN users u ON rt.user_id = u.id
WHERE rt.token_hash = $1`,
[tokenHash]
);
const tokenRecord = result.rows[0];
// Token tidak ditemukan
if (!tokenRecord) {
throw Object.assign(new Error('Refresh token tidak valid'), { statusCode: 401 });
}
// Token sudah direvoke — kemungkinan token theft!
if (tokenRecord.revoked_at) {
// Revoke SEMUA token milik user ini (token family invalidation)
await pool.query(
`UPDATE refresh_tokens SET revoked_at = NOW(), revoked_reason = 'suspicious'
WHERE user_id = $1 AND revoked_at IS NULL`,
[tokenRecord.user_id]
);
throw Object.assign(
new Error('Token tidak valid. Semua sesi telah dihentikan karena aktivitas mencurigakan.'),
{ statusCode: 401 }
);
}
// Token sudah expired
if (new Date(tokenRecord.expires_at) < new Date()) {
throw Object.assign(new Error('Refresh token sudah kadaluarsa'), { statusCode: 401 });
}
if (!tokenRecord.is_active) {
throw Object.assign(new Error('Akun telah dinonaktifkan'), { statusCode: 403 });
}
// Revoke token lama
await pool.query(
`UPDATE refresh_tokens SET revoked_at = NOW(), revoked_reason = 'rotated'
WHERE id = $1`,
[tokenRecord.id]
);
// Issue token baru
const user = {
id: tokenRecord.user_id,
email: tokenRecord.email,
role: tokenRecord.role,
};
const accessToken = generateAccessToken(user);
const { token: newRefreshToken, hash: newRefreshTokenHash } = generateRefreshToken();
await pool.query(
`INSERT INTO refresh_tokens
(user_id, token_hash, device_name, device_ip, user_agent, expires_at)
SELECT $1, $2, device_name, device_ip, user_agent, NOW() + INTERVAL '7 days'
FROM refresh_tokens WHERE id = $3`,
[tokenRecord.user_id, newRefreshTokenHash, tokenRecord.id]
);
return { accessToken, refreshToken: newRefreshToken };
},
};// src/controllers/auth.controller.js (lanjutan)
export async function refresh(req, res, next) {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ success: false, message: 'Refresh token diperlukan' });
}
const tokens = await AuthService.refreshTokens(refreshToken);
res.json({ success: true, data: tokens });
} catch (error) {
next(error);
}
}Bab 5: Token Revocation & Logout
Logout kedengarannya sepele, tapi ini justru tempat paling banyak implementasi auth setengah-setengah gagal — token "dihapus" di client, padahal di server masih dianggap valid. Bab ini menutup celah itu.
Logout Single Device
Untuk logout dari satu device saja, cukup revoke refresh token milik sesi itu:
// src/services/auth.service.js (lanjutan)
export const AuthService = {
// ...
async logout(refreshToken) {
const crypto = await import('crypto');
const tokenHash = crypto
.createHash('sha256')
.update(refreshToken)
.digest('hex');
await pool.query(
`UPDATE refresh_tokens
SET revoked_at = NOW(), revoked_reason = 'logout'
WHERE token_hash = $1 AND revoked_at IS NULL`,
[tokenHash]
);
// Tidak perlu error jika token tidak ditemukan — anggap sudah logout
},
async logoutAllDevices(userId) {
await pool.query(
`UPDATE refresh_tokens
SET revoked_at = NOW(), revoked_reason = 'logout_all'
WHERE user_id = $1 AND revoked_at IS NULL`,
[userId]
);
},
};Invalidasi Access Token yang Masih Valid
Access token tidak bisa dicabut secara langsung karena stateless. Tapi ada situasi di mana kamu perlu "paksa logout" user sebelum access token-nya expired — misal saat password diganti atau akun di-suspend.
Solusinya: token blacklist di Redis dengan TTL = sisa waktu token expired.
npm install ioredis// src/utils/tokenBlacklist.js
import Redis from 'ioredis';
import { config } from '../config/index.js';
const redis = new Redis(config.redis.url);
export async function blacklistToken(jti, expiresAt) {
const ttlSeconds = Math.max(0, Math.floor((expiresAt * 1000 - Date.now()) / 1000));
if (ttlSeconds > 0) {
await redis.setex(`blacklist:${jti}`, ttlSeconds, '1');
}
}
export async function isTokenBlacklisted(jti) {
const result = await redis.get(`blacklist:${jti}`);
return result !== null;
}Update middleware auth untuk cek blacklist:
// src/middleware/auth.middleware.js
import { verifyAccessToken } from '../utils/token.js';
import { isTokenBlacklisted } from '../utils/tokenBlacklist.js';
export async function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ success: false, message: 'Token autentikasi diperlukan' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = verifyAccessToken(token);
// Cek blacklist (hanya jika token punya jti)
if (decoded.jti && await isTokenBlacklisted(decoded.jti)) {
return res.status(401).json({ success: false, message: 'Token tidak lagi valid' });
}
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ success: false, message: 'Token tidak valid atau kadaluarsa' });
}
}Force Logout (Password Change, Akun Suspend)
Ganti password wajib memicu logout paksa di semua device — kalau tidak, orang yang mencuri password lama tetap punya sesi aktif walau password itu sudah tidak berlaku. Begini menyatukan revoke refresh token dan blacklist access token dalam satu alur:
// src/services/auth.service.js (lanjutan)
import { blacklistToken } from '../utils/tokenBlacklist.js';
export const AuthService = {
// ...
async changePassword(userId, currentPassword, newPassword, currentAccessToken) {
const user = await pool.query(
'SELECT password_hash FROM users WHERE id = $1',
[userId]
);
const match = await bcrypt.compare(currentPassword, user.rows[0].password_hash);
if (!match) {
throw Object.assign(new Error('Password saat ini tidak benar'), { statusCode: 400 });
}
const newHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
await pool.query(
'UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2',
[newHash, userId]
);
// Revoke semua refresh token (logout semua device)
await pool.query(
`UPDATE refresh_tokens SET revoked_at = NOW(), revoked_reason = 'password_change'
WHERE user_id = $1 AND revoked_at IS NULL`,
[userId]
);
// Blacklist access token yang sedang dipakai
if (currentAccessToken) {
const decoded = verifyAccessToken(currentAccessToken);
if (decoded.jti) {
await blacklistToken(decoded.jti, decoded.exp);
}
}
},
};Bab 6: Keamanan JWT
Alur autentikasinya sudah lengkap. Sebelum lanjut ke role dan OAuth, saya mau kumpulkan dulu kesalahan-kesalahan yang paling sering bikin implementasi JWT yang kelihatannya benar ternyata bocor.
Algorithm Confusion Attack (alg: none)
Ini salah satu serangan paling berbahaya di dunia JWT, dan celakanya sering luput karena kelihatan seperti detail teknis kecil. Kalau library tidak memvalidasi algorithm secara eksplisit, attacker bisa membuat token dengan "alg": "none" — tidak ada signature yang diverifikasi.
// ✗ SALAH — tidak specify algorithm
jwt.verify(token, secret);
// ✓ BENAR — selalu explicit
jwt.verify(token, secret, { algorithms: ['HS256'] });Library jsonwebtoken modern sudah melindungi ini secara default, tapi tetap jadikan kebiasaan untuk selalu specify.
Kekuatan Secret Key
Secret pendek atau gampang ditebak membuat semua langkah keamanan sebelumnya percuma — HS256 sekuat secret-nya, tidak lebih. Jangan pernah pakai yang gampang ditebak:
// ✗ SALAH — terlalu pendek dan predictable
JWT_SECRET=secret
JWT_SECRET=myapp123
// ✓ BENAR — generate dengan crypto
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
// Output: 8f4e2a9d1b6c7e3f0a5d8c9b2e7f4a1d...Minimum 32 bytes (64 karakter hex). Simpan di .env, jangan pernah commit ke git.
Token Storage: Threat Model
Token yang di-generate dengan benar tapi disimpan sembarangan di client tetap saja bocor. Sebelum memilih tempat penyimpanan, timbang dulu ancaman mana yang paling relevan buat aplikasimu:
| Storage | Aman dari XSS | Aman dari CSRF | Cocok untuk |
|---|---|---|---|
localStorage | ✗ (bisa dicuri JS) | ✓ | Bukan pilihan untuk token auth |
sessionStorage | ✗ | ✓ | Bukan pilihan untuk token auth |
httpOnly cookie | ✓ | ✗ (perlu CSRF token) | Web app yang punya backend server |
| Memory (JS variable) | ✓ | ✓ | SPA — hilang saat refresh page |
Authorization header | ✓ jika tidak disimpan | ✓ | Mobile app, API client |
Rekomendasi untuk web app:
- Access token di memory (JavaScript variable) — hilang saat refresh, tapi aman dari XSS
- Refresh token di
httpOnlycookie — tidak bisa diakses JavaScript
Rekomendasi untuk mobile app:
- Gunakan secure storage platform (iOS Keychain, Android Keystore)
Expiry Strategy
Berapa lama token boleh hidup itu bukan angka sembarangan — ada trade-off antara kenyamanan (tidak perlu login ulang terus) dan risiko (window waktu kalau token dicuri). Angka berikut yang biasa saya pakai sebagai titik awal:
// Access token: pendek — 15 menit adalah sweet spot
// Cukup cepat untuk operasional, cukup singkat untuk limitasi damage jika dicuri
accessExpiresIn: '15m'
// Refresh token: 7 hari untuk consumer app, 30 hari untuk mobile yang jarang dipakai
// 1 hari untuk aplikasi keuangan/enterprise
refreshExpiresIn: '7d'
// Jangan buat access token yang terlalu panjang:
// expiresIn: '24h' → window 24 jam jika token dicuri
// expiresIn: '30d' → sama saja dengan tidak punya expiryJangan Taruh Data Sensitif di Payload
Ini konsekuensi langsung dari poin paling awal di Bab 1: karena payload JWT bisa dibaca siapa saja tanpa perlu secret, apapun yang kamu taruh di sana harus dianggap sudah publik:
// ✗ JANGAN — payload bisa dibaca siapapun
jwt.sign({
userId: 123,
password: 'user_password', // JANGAN
creditCard: '4111111111111111', // JANGAN
internalApiKey: 'sk-...', // JANGAN
});
// ✓ Hanya data yang aman diketahui publik
jwt.sign({
sub: '123',
email: 'user@email.com',
role: 'admin',
});Bab 7: Role-Based Access Control (RBAC)
Punya user yang berhasil login saja belum cukup — kamu juga perlu mengontrol siapa boleh melakukan apa. Di sinilah role dan permission masuk.
Middleware authorize
authenticate cuma menjawab "siapa kamu". Middleware ini menjawab pertanyaan berikutnya, "kamu boleh ngapain" — dan harus selalu dipasang setelah authenticate, bukan sebelum:
// src/middleware/auth.middleware.js (lanjutan)
// Gunakan setelah authenticate
export function authorize(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ success: false, message: 'Autentikasi diperlukan' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({
success: false,
message: 'Kamu tidak memiliki akses untuk melakukan tindakan ini',
});
}
next();
};
}Penggunaan di routes:
import { authenticate, authorize } from '../middleware/auth.middleware.js';
// Hanya admin
router.delete('/:id', authenticate, authorize('admin'), deleteProduct);
// Admin atau manager
router.put('/:id', authenticate, authorize('admin', 'manager'), updateProduct);
// Semua user yang sudah login
router.get('/me', authenticate, getProfile);Permission-Based Access Control
Untuk kontrol yang lebih granular dari sekadar role:
-- Schema untuk permissions
CREATE TABLE permissions (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE -- 'products:read', 'products:delete', 'users:manage'
);
CREATE TABLE role_permissions (
role VARCHAR(50) NOT NULL,
permission VARCHAR(100) NOT NULL REFERENCES permissions(name),
PRIMARY KEY (role, permission)
);
-- Setup default permissions
INSERT INTO role_permissions VALUES
('admin', 'products:read'),
('admin', 'products:write'),
('admin', 'products:delete'),
('admin', 'users:manage'),
('manager', 'products:read'),
('manager', 'products:write'),
('user', 'products:read');// src/middleware/auth.middleware.js
export function can(permission) {
return async (req, res, next) => {
if (!req.user) {
return res.status(401).json({ success: false, message: 'Autentikasi diperlukan' });
}
const result = await pool.query(
'SELECT 1 FROM role_permissions WHERE role = $1 AND permission = $2',
[req.user.role, permission]
);
if (result.rows.length === 0) {
return res.status(403).json({
success: false,
message: `Akses ditolak: membutuhkan permission '${permission}'`,
});
}
next();
};
}// Routes dengan permission granular
router.delete('/:id', authenticate, can('products:delete'), deleteProduct);
router.get('/users', authenticate, can('users:manage'), listUsers);Trade-off: Mengecek permission ke database setiap request menambah latency. Alternatif: encode permissions di JWT payload saat login (cepat tapi tidak bisa diubah real-time tanpa force-refresh token).
Bab 8: OAuth2: Login dengan Google & GitHub
Konsep OAuth2 Authorization Code Flow
Ini bagian yang paling sering bikin pemula mundur duluan dari OAuth2 — tiba-tiba ada empat pihak yang terlibat, bukan cuma client dan server seperti login biasa. Sabar dulu di diagram ini, baca alurnya pelan-pelan dari kiri ke kanan; begitu urutannya nempel di kepala, implementasinya di kode justru terasa lebih mekanis daripada konseptual.
[User] [Client App] [Google] [Your API]
| | | |
|-- Klik Login --->| | |
| |-- Redirect ------->| |
| | (client_id, | |
| | redirect_uri, | |
| | scope, state) | |
| | | |
|<---------- Login Google + Consent ----| |
| | | |
| |<-- Authorization --| |
| | Code | |
| | | |
| |-- Exchange Code ------------------> |
| | (code + |
| | client_secret) |
| |<-- accessToken + user info ---------|
|<-- JWT token ----| | |
Setup Google OAuth
Sebelum ada satu baris kode pun, Google perlu tahu aplikasimu itu apa. Daftarkan dulu OAuth client-nya di Google Cloud Console:
- Buka console.cloud.google.com
- Buat project → APIs & Services → Credentials → Create OAuth Client ID
- Application type: Web application
- Authorized redirect URIs:
http://localhost:3000/api/v1/auth/google/callback - Simpan
GOOGLE_CLIENT_IDdanGOOGLE_CLIENT_SECRETke.env
npm install axios// src/services/oauth.service.js
import axios from 'axios';
import pool from '../db/pool.js';
import { generateAccessToken, generateRefreshToken } from '../utils/token.js';
import { config } from '../config/index.js';
export const OAuthService = {
// Step 1: Generate authorization URL
getGoogleAuthUrl(state) {
const params = new URLSearchParams({
client_id: config.google.clientId,
redirect_uri: config.google.redirectUri,
response_type: 'code',
scope: 'openid email profile',
state, // random string untuk mencegah CSRF
access_type: 'offline', // untuk dapat refresh token dari Google
});
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
},
// Step 2: Exchange code untuk token
async handleGoogleCallback(code, deviceName, deviceIp) {
// Exchange authorization code untuk access token
const tokenResponse = await axios.post('https://oauth2.googleapis.com/token', {
code,
client_id: config.google.clientId,
client_secret: config.google.clientSecret,
redirect_uri: config.google.redirectUri,
grant_type: 'authorization_code',
});
const { access_token } = tokenResponse.data;
// Ambil data user dari Google
const userInfo = await axios.get('https://www.googleapis.com/oauth2/v3/userinfo', {
headers: { Authorization: `Bearer ${access_token}` },
});
const { sub: googleId, email, name, picture } = userInfo.data;
return this._findOrCreateOAuthUser({
provider: 'google',
providerId: googleId,
email,
name,
profileData: userInfo.data,
deviceName,
deviceIp,
});
},
async _findOrCreateOAuthUser({ provider, providerId, email, name, profileData, deviceName, deviceIp }) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Cek apakah oauth account sudah ada
let oauthAccount = await client.query(
'SELECT oa.*, u.id as user_id FROM oauth_accounts oa JOIN users u ON oa.user_id = u.id WHERE oa.provider = $1 AND oa.provider_id = $2',
[provider, providerId]
);
let userId;
if (oauthAccount.rows.length > 0) {
// User sudah pernah login dengan provider ini
userId = oauthAccount.rows[0].user_id;
await client.query(
'UPDATE oauth_accounts SET profile_data = $1, updated_at = NOW() WHERE provider = $2 AND provider_id = $3',
[profileData, provider, providerId]
);
} else {
// Cek apakah email sudah ada (link ke akun existing)
let existingUser = await client.query(
'SELECT id FROM users WHERE email = $1',
[email]
);
if (existingUser.rows.length > 0) {
userId = existingUser.rows[0].id;
} else {
// Buat user baru
const newUser = await client.query(
`INSERT INTO users (name, email, email_verified, email_verified_at)
VALUES ($1, $2, true, NOW())
RETURNING id`,
[name, email]
);
userId = newUser.rows[0].id;
}
// Hubungkan OAuth account ke user
await client.query(
`INSERT INTO oauth_accounts (user_id, provider, provider_id, profile_data)
VALUES ($1, $2, $3, $4)`,
[userId, provider, providerId, profileData]
);
}
await client.query('COMMIT');
// Ambil data user untuk token
const user = await pool.query(
'SELECT id, name, email, role FROM users WHERE id = $1',
[userId]
);
const accessToken = generateAccessToken(user.rows[0]);
const { token: refreshToken, hash: refreshTokenHash } = generateRefreshToken();
await pool.query(
`INSERT INTO refresh_tokens (user_id, token_hash, device_name, device_ip, expires_at)
VALUES ($1, $2, $3, $4, NOW() + INTERVAL '7 days')`,
[userId, refreshTokenHash, deviceName, deviceIp]
);
return { user: user.rows[0], accessToken, refreshToken };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
},
};Routes OAuth
Dua route ini yang menjahit alur di diagram tadi jadi kode nyata — satu untuk redirect awal ke Google, satu lagi untuk menangkap callback-nya. Perhatikan verifikasi state di callback, itu yang mencegah CSRF:
// src/routes/auth.routes.js
import crypto from 'crypto';
import { OAuthService } from '../services/oauth.service.js';
// Step 1: Redirect ke Google
router.get('/google', (req, res) => {
const state = crypto.randomBytes(16).toString('hex');
// Simpan state di session/cookie untuk verifikasi callback
res.cookie('oauth_state', state, { httpOnly: true, maxAge: 10 * 60 * 1000 });
res.redirect(OAuthService.getGoogleAuthUrl(state));
});
// Step 2: Handle callback dari Google
router.get('/google/callback', async (req, res, next) => {
try {
const { code, state } = req.query;
const savedState = req.cookies.oauth_state;
// Verifikasi state untuk mencegah CSRF
if (!state || state !== savedState) {
return res.status(400).json({ success: false, message: 'State tidak valid' });
}
res.clearCookie('oauth_state');
const result = await OAuthService.handleGoogleCallback(
code,
req.headers['x-device-name'],
req.ip
);
// Redirect ke frontend dengan token
// Atau return JSON jika mobile app
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
});GitHub OAuth
Polanya sama dengan Google — hanya URL dan endpoint yang berbeda:
getGitHubAuthUrl(state) {
const params = new URLSearchParams({
client_id: config.github.clientId,
redirect_uri: config.github.redirectUri,
scope: 'user:email',
state,
});
return `https://github.com/login/oauth/authorize?${params}`;
},
async handleGitHubCallback(code, deviceName, deviceIp) {
// Exchange code
const tokenResponse = await axios.post(
'https://github.com/login/oauth/access_token',
{
client_id: config.github.clientId,
client_secret: config.github.clientSecret,
code,
},
{ headers: { Accept: 'application/json' } }
);
const { access_token } = tokenResponse.data;
// Ambil data user
const [userInfo, emailsInfo] = await Promise.all([
axios.get('https://api.github.com/user', {
headers: { Authorization: `Bearer ${access_token}` },
}),
axios.get('https://api.github.com/user/emails', {
headers: { Authorization: `Bearer ${access_token}` },
}),
]);
// Ambil primary email (GitHub bisa sembunyikan email di profile)
const primaryEmail = emailsInfo.data.find(e => e.primary)?.email;
return this._findOrCreateOAuthUser({
provider: 'github',
providerId: userInfo.data.id.toString(),
email: primaryEmail || userInfo.data.email,
name: userInfo.data.name || userInfo.data.login,
profileData: userInfo.data,
deviceName,
deviceIp,
});
},Bab 9: Multi-Device Session Management
Satu user modern jarang login dari satu device saja — laptop, HP, kadang tablet sekaligus. Bab penutup ini memberi user kontrol atas sesi-sesi itu: lihat siapa yang sedang login, dan matikan yang mencurigakan.
Lihat Semua Sesi Aktif
Karena tiap refresh token menyimpan device dan IP saat dibuat, kita bisa kasih user daftar sesi aktifnya sendiri — device apa saja yang sedang login, dan kapan terakhir dipakai:
// src/services/auth.service.js (lanjutan)
export const AuthService = {
// ...
async getActiveSessions(userId) {
const result = await pool.query(
`SELECT
id,
device_name,
device_ip,
user_agent,
last_used_at,
created_at,
expires_at
FROM refresh_tokens
WHERE user_id = $1
AND revoked_at IS NULL
AND expires_at > NOW()
ORDER BY last_used_at DESC NULLS LAST`,
[userId]
);
return result.rows;
},
async revokeSession(userId, sessionId) {
const result = await pool.query(
`UPDATE refresh_tokens
SET revoked_at = NOW(), revoked_reason = 'manual_revoke'
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL
RETURNING id`,
[sessionId, userId]
);
if (result.rows.length === 0) {
throw Object.assign(new Error('Sesi tidak ditemukan'), { statusCode: 404 });
}
},
};// src/controllers/auth.controller.js (lanjutan)
export async function getSessions(req, res, next) {
try {
const sessions = await AuthService.getActiveSessions(req.user.sub);
res.json({ success: true, data: sessions });
} catch (error) {
next(error);
}
}
export async function revokeSession(req, res, next) {
try {
await AuthService.revokeSession(req.user.sub, req.params.sessionId);
res.json({ success: true, message: 'Sesi berhasil dihentikan' });
} catch (error) {
next(error);
}
}Endpoint Summary
Setelah sembilan bab, ini rangkuman semua endpoint auth yang sudah kita bangun dalam satu file routes — sengaja dipisah jelas mana yang publik dan mana yang butuh authenticate:
// src/routes/auth.routes.js
import { authenticate } from '../middleware/auth.middleware.js';
import * as AuthController from '../controllers/auth.controller.js';
// Public
router.post('/register', validate(registerSchema), AuthController.register);
router.post('/login', authLimiter, validate(loginSchema), AuthController.login);
router.post('/refresh', AuthController.refresh);
router.get('/google', AuthController.googleRedirect);
router.get('/google/callback', AuthController.googleCallback);
router.get('/github', AuthController.githubRedirect);
router.get('/github/callback', AuthController.githubCallback);
// Protected
router.post('/logout', authenticate, AuthController.logout);
router.post('/logout-all', authenticate, AuthController.logoutAll);
router.put('/password', authenticate, AuthController.changePassword);
router.get('/sessions', authenticate, AuthController.getSessions);
router.delete('/sessions/:sessionId', authenticate, AuthController.revokeSession);Penutup
Auth itu salah satu bagian backend yang paling gampang terasa "selesai" padahal belum — login jalan, token terbit, respons 200, kelihatannya beres. Yang sebenarnya membedakan sistem auth yang aman dari yang cuma terlihat aman ada di detail-detail yang jarang dibahas tutorial lain: token yang benar-benar bisa dicabut kapanpun, bukan cuma menunggu expired; kemampuan mendeteksi kalau ada refresh token yang dicuri dan dipakai diam-diam; dan sesi per device yang bisa kamu lihat serta matikan satu per satu. Itu yang kita bangun di artikel ini.
Production Checklist
Langkah selanjutnya:
- Tambahkan email verification flow (artikel ini sudah siapkan tabelnya)
- Implementasikan 2FA dengan TOTP (Google Authenticator)
- Eksplorasi Passport.js untuk abstraksi OAuth yang lebih mudah di-maintain