Kenapa ini penting
Autentikasi (authentication) menjawab satu pertanyaan: "kamu ini siapa?". Tanpa autentikasi, server tidak bisa membedakan Budi dari Siti, apalagi dari penyerang. Keranjang belanja, riwayat pesanan, dan halaman admin semuanya bergantung pada jawaban pertanyaan itu.
Autentikasi juga area yang paling sering dibobol. Database bocor itu kejadian rutin di berita. Yang membedakan "bocor tapi password pengguna tetap aman" dari "jutaan password terbongkar" adalah cara password disimpan. Yang membedakan "sesi aman" dari "akun bisa dibajak lewat satu skrip jahat" adalah cara token disimpan di browser. Modul ini membahas keduanya dengan cara yang dipakai di industri.
Analogi yang akan kita pakai: autentikasi itu seperti resepsionis hotel. Saat check-in kamu menunjukkan KTP (email + password). Resepsionis mencocokkannya, lalu memberimu kartu kamar (session atau token). Setelah itu kamu tidak perlu menunjukkan KTP lagi setiap mau masuk lift. Cukup tempelkan kartunya.
Konsep inti
Jangan pernah menyimpan password apa adanya
Password tidak boleh disimpan sebagai teks asli (plaintext), dan juga tidak boleh dienkripsi (karena yang bisa dienkripsi bisa didekripsi). Yang benar adalah hash: fungsi satu arah yang mengubah rahasia123 menjadi deretan karakter yang tidak bisa dibalik. Saat login, password yang diketik di-hash ulang lalu dicocokkan.
Tapi tidak sembarang hash. SHA-256 atau MD5 dirancang cepat, dan justru itu masalahnya: GPU modern bisa mencoba miliaran tebakan per detik. Untuk password, kita butuh hash yang sengaja lambat dan memakai salt (data acak unik per password, supaya dua orang dengan password sama punya hash berbeda dan tabel tebakan siap pakai jadi tidak berguna). Dua pilihan utama:
argon2 (paket argon2 0.45.1) |
bcrypt (paket bcrypt 6.0.0) |
|
|---|---|---|
| Status | Pemenang Password Hashing Competition 2015, rekomendasi utama OWASP (varian argon2id) | Sudah dipakai puluhan tahun, masih aman |
| Kelebihan | Boros memori, sehingga mahal untuk diserang dengan GPU | Didukung di mana-mana |
| Batasan | Paket native (butuh binary yang cocok platform) | Hanya membaca 72 byte pertama password |
| API | argon2.hash(pw), argon2.verify(hash, pw) |
bcrypt.hash(pw, 12), bcrypt.compare(pw, hash) |
Kedua library menyimpan salt dan parameternya di dalam string hash, jadi kamu cukup menyimpan satu kolom password_hash.
Alur register dan login
Register: validasi input → cek email belum dipakai → hash password → simpan user → langsung loginkan (pasang cookie).
Login: validasi input → cari user berdasarkan email → verify password → kalau cocok, terbitkan session/token. Dua detail penting:
- Pesan error untuk "email tidak terdaftar" dan "password salah" harus sama ("Email atau password salah"). Kalau berbeda, penyerang bisa mengetahui email mana saja yang terdaftar.
- Kalau email tidak ditemukan, tetap jalankan
verifyterhadap hash palsu. Tanpa ini, respons untuk email tak terdaftar jauh lebih cepat (tidak ada hashing), dan perbedaan waktu itu juga membocorkan informasi.
Session cookie vs JWT
Setelah login berhasil, server harus memberi "kartu kamar". Ada dua pendekatan:
| Session (stateful) | JWT (stateless) | |
|---|---|---|
| Isi kartu | ID acak, misalnya sid=8f3a... |
Data user yang ditandatangani, misalnya { sub: 42, role: 'user', exp: ... } |
| Disimpan di server | Ya, tabel/penyimpanan session (database, Redis) | Tidak, server cukup memverifikasi tanda tangan |
| Logout / cabut akses | Mudah: hapus session di server | Sulit: token tetap valid sampai kedaluwarsa, kecuali kamu membuat daftar blokir |
| Skala ke banyak server | Semua server harus membaca penyimpanan session yang sama | Server mana pun bisa memverifikasi dengan kunci yang sama |
| Cocok untuk | Aplikasi web biasa, panel admin | API yang dipakai banyak layanan, aplikasi mobile |
JWT (JSON Web Token) terdiri dari tiga bagian yang dipisah titik: header.payload.signature. Header dan payload hanya di-encode base64url. Siapa pun bisa membacanya, jadi jangan pernah menaruh data rahasia di payload. Yang membuat JWT bisa dipercaya adalah signature: hasil tanda tangan dengan kunci rahasia server. Ubah satu huruf payload (misalnya role jadi admin), dan tanda tangannya tidak cocok lagi.
Di materi ini kita memakai library jose 6.2.13 karena ESM-native, tanpa dependency, dan berjalan di Node maupun edge runtime. jsonwebtoken 9.0.3 adalah alternatif lama yang masih banyak dipakai. API-nya ditunjukkan di contoh kode supaya kamu mengenalinya di proyek orang lain.
Keduanya bukan pilihan "benar vs salah". Yang paling penting adalah di mana kartunya disimpan di browser.
Cookie httpOnly: tempat terbaik untuk token di browser
Banyak tutorial menyimpan JWT di localStorage. Masalahnya, localStorage bisa dibaca semua JavaScript di halaman itu, termasuk skrip jahat hasil serangan XSS (Cross-Site Scripting) atau paket npm yang tersusupi. Sekali token terbaca, akun bisa dibajak dari mana saja.
Cookie dengan atribut yang tepat jauh lebih aman:
httpOnly: JavaScript di browser (document.cookie) tidak bisa membacanya. Browser tetap mengirimnya otomatis ke server.secure: hanya dikirim lewat HTTPS. Nyalakan di produksi (dilocalhosttanpa HTTPS, matikan supaya bisa dites).sameSite: 'lax': cookie tidak ikut terkirim pada request POST dari situs lain. Ini mengurangi risiko CSRF (Cross-Site Request Forgery), yaitu situs jahat yang diam-diam mengirim request atas nama pengguna yang sedang login.maxAge: umur cookie. Samakan dengan umur token.
Untuk membaca cookie di Express, pasang middleware cookie-parser (1.4.7). Isinya akan tersedia di req.cookies.
Refresh token, singkatnya
Token yang berumur panjang berbahaya kalau bocor. Token yang berumur pendek (misalnya 15 menit) membuat pengguna harus sering login. Jalan tengahnya adalah pasangan token:
- Access token: umur pendek (5-15 menit), dikirim di setiap request.
- Refresh token: umur panjang (7-30 hari), dipakai hanya untuk meminta access token baru di satu endpoint, misalnya
POST /api/auth/refresh.
Setiap kali refresh dipakai, terbitkan refresh token baru (rotasi). Di sistem produksi, refresh token biasanya juga dicatat di database supaya bisa dicabut saat logout atau saat dicurigai bocor. Untuk Proyek Akhir kita memakai satu token 2 jam saja supaya alurnya mudah diikuti. Contoh refresh lengkap ada di Latihan 3.
Contoh kode
Hash password dengan argon2 dan bcrypt (output asli):
// hash.js, hash password dengan argon2 dan bcrypt
import argon2 from 'argon2';
import bcrypt from 'bcrypt';
const password = 'rahasia123';
const h1 = await argon2.hash(password); // default: argon2id + salt acak
const h2 = await argon2.hash(password);
console.log(h1);
// → $argon2id$v=19$m=65536,p=4,t=3$wK2NC+Cbwv5Iu6rkzp80Pg$Z6nfeQ5Q0zpCLEFEKmDySA2yLGsYOPq//95Us3067oU
// (varian, versi, memori 64 MB, paralelisme 4, 3 iterasi, salt, hash)
console.log(h1 === h2); // → false (salt beda tiap kali)
console.log(await argon2.verify(h1, password)); // → true
console.log(await argon2.verify(h1, 'rahasia124')); // → false
const b = await bcrypt.hash(password, 12); // 12 = cost factor (2^12 putaran)
console.log(b); // → $2b$12$pOzAlncg9Pt97LVY8XoujeJOq...
console.log(await bcrypt.compare(password, b)); // → true
// berapa lama satu hash? (sengaja lambat supaya brute force mahal)
let t = performance.now(); await argon2.hash(password);
console.log('argon2:', Math.round(performance.now() - t), 'ms'); // → argon2: 26 ms
t = performance.now(); await bcrypt.hash(password, 12);
console.log('bcrypt:', Math.round(performance.now() - t), 'ms'); // → bcrypt: 218 ms
Angka waktunya diukur di sebuah Mac mini dan akan berbeda di mesinmu. Intinya: puluhan sampai ratusan milidetik itu tidak terasa bagi pengguna yang login sekali, tapi sangat mahal bagi penyerang yang ingin mencoba miliaran tebakan.
Membuat dan memverifikasi JWT dengan jose:
// jwt.js, membuat dan memverifikasi JWT dengan jose 6
import { SignJWT, jwtVerify, decodeJwt } from 'jose';
const key = new TextEncoder().encode('rahasia-minimal-32-karakter-panjangnya-ya!');
const token = await new SignJWT({ role: 'user' })
.setProtectedHeader({ alg: 'HS256' })
.setSubject('42') // "sub" = id user
.setIssuedAt()
.setExpirationTime('15m')
.sign(key);
console.log(token.split('.').length); // → 3 (header.payload.signature)
// payload BISA DIBACA siapa saja (cuma base64url), jadi jangan taruh rahasia di sini
console.log(decodeJwt(token)); // → { role: 'user', sub: '42', iat: 1792779303, exp: 1792780203 }
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
console.log(payload.sub, payload.role); // → 42 user
// penyerang mengubah role di payload → tanda tangan tidak cocok
const [h, p, s] = token.split('.');
const palsu = Buffer.from(JSON.stringify({ ...decodeJwt(token), role: 'admin' })).toString('base64url');
try { await jwtVerify(`${h}.${palsu}.${s}`, key); }
catch (e) { console.log(e.code); } // → ERR_JWS_SIGNATURE_VERIFICATION_FAILED
// token kedaluwarsa
const basi = await new SignJWT({}).setProtectedHeader({ alg: 'HS256' }).setExpirationTime('-1s').sign(key);
try { await jwtVerify(basi, key); }
catch (e) { console.log(e.code); } // → ERR_JWT_EXPIRED
Versi jsonwebtoken, supaya kamu mengenalinya:
import jwt from 'jsonwebtoken';
const secret = 'rahasia-minimal-32-karakter-panjangnya-ya!';
const token = jwt.sign({ role: 'user' }, secret, { subject: '42', expiresIn: '15m', algorithm: 'HS256' });
const data = jwt.verify(token, secret, { algorithms: ['HS256'] });
console.log(data.sub, data.role); // → 42 user
try { jwt.verify(token, 'kunci-lain'); } catch (e) { console.log(e.name, e.message); }
// → JsonWebTokenError invalid signature
Sekarang dirangkai menjadi helper autentikasi untuk Express. File ini dipakai di Proyek Akhir:
// src/middleware/auth.js, token JWT di cookie httpOnly
import { SignJWT, jwtVerify } from 'jose';
export const COOKIE_NAME = 'token';
export function makeAuth(config) {
const key = new TextEncoder().encode(config.JWT_SECRET);
async function signToken(user) {
return new SignJWT({ role: user.role })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(String(user.id))
.setIssuedAt()
.setExpirationTime('2h')
.sign(key);
}
function setAuthCookie(res, token) {
res.cookie(COOKIE_NAME, token, {
httpOnly: true, // JavaScript di browser tidak bisa membaca
secure: config.isProd, // hanya lewat HTTPS di produksi
sameSite: 'lax', // mengurangi risiko CSRF
maxAge: 2 * 60 * 60 * 1000, // 2 jam, samakan dengan exp token
path: '/',
});
}
// requireAuth & requireRole dibahas di Modul 4
// ...
return { signToken, setAuthCookie /*, requireAuth, requireRole */ };
}
Route register, login, logout, dan me:
// src/routes/auth.js, register, login, logout, me
import { Router } from 'express';
import argon2 from 'argon2';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { users } from '../db/schema.js';
import { validate } from '../middleware/validate.js';
import { COOKIE_NAME } from '../middleware/auth.js';
const RegisterSchema = z.object({
email: z.email().transform((e) => e.toLowerCase()),
name: z.string().trim().min(2).max(60),
password: z.string().min(8).max(128),
});
const LoginSchema = z.object({
email: z.email().transform((e) => e.toLowerCase()),
password: z.string().min(1).max(128),
});
// hash palsu untuk menyamakan waktu respons saat email tidak terdaftar
const DUMMY_HASH = await argon2.hash('password-palsu-untuk-timing');
const publicUser = (u) => ({ id: u.id, email: u.email, name: u.name, role: u.role });
export function authRouter({ db, auth, authLimiter }) {
const r = Router();
r.post('/register', authLimiter, validate(RegisterSchema), async (req, res) => {
const { email, name, password } = req.body;
const ada = db.select({ id: users.id }).from(users).where(eq(users.email, email)).get();
if (ada) return res.status(409).json({ error: 'Email sudah terdaftar' });
const passwordHash = await argon2.hash(password); // argon2id, salt otomatis
// role TIDAK diambil dari body: user tidak boleh mendaftarkan diri jadi admin
const user = db.insert(users).values({ email, name, passwordHash }).returning().get();
auth.setAuthCookie(res, await auth.signToken(user));
res.status(201).json(publicUser(user)); // JANGAN kirim passwordHash ke klien
});
r.post('/login', authLimiter, validate(LoginSchema), async (req, res) => {
const { email, password } = req.body;
const user = db.select().from(users).where(eq(users.email, email)).get();
const cocok = await argon2.verify(user?.passwordHash ?? DUMMY_HASH, password);
if (!user || !cocok) {
return res.status(401).json({ error: 'Email atau password salah' }); // pesan SAMA untuk dua kasus
}
auth.setAuthCookie(res, await auth.signToken(user));
res.json(publicUser(user));
});
r.post('/logout', (req, res) => {
res.clearCookie(COOKIE_NAME, { path: '/' });
res.status(204).end();
});
r.get('/me', auth.requireAuth, (req, res) => {
const user = db.select().from(users).where(eq(users.id, req.user.id)).get();
if (!user) return res.status(401).json({ error: 'User tidak ada' });
res.json(publicUser(user));
});
return r;
}
Diuji dengan curl ke server Proyek Akhir yang sedang berjalan. -c menyimpan cookie ke file, -D - menampilkan header respons:
curl -s -c admin.txt -X POST http://localhost:3000/api/auth/login \
-H 'content-type: application/json' -d '{"email":"admin@toko.id","password":"admin12345"}'
# → {"id":1,"email":"admin@toko.id","name":"Admin","role":"admin"}
curl -s -D - -o /dev/null -X POST http://localhost:3000/api/auth/login \
-H 'content-type: application/json' -d '{"email":"admin@toko.id","password":"admin12345"}' | grep -i set-cookie
# → Set-Cookie: token=eyJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiYWRtaW4iLCJzdWIiOiIxIi...; Max-Age=7200; Path=/;
# Expires=Fri, 23 Oct 2026 20:12:55 GMT; HttpOnly; SameSite=Lax
curl -s -X POST http://localhost:3000/api/auth/register -H 'content-type: application/json' \
-d '{"email":"budi@mail.com","name":"Budi","password":"rahasia123"}'
# → {"id":2,"email":"budi@mail.com","name":"Budi","role":"user"}
curl -s -D - -o /dev/null -b admin.txt -X POST http://localhost:3000/api/auth/logout | grep -iE "^(HTTP|set-cookie)"
# → HTTP/1.1 204 No Content
# → Set-Cookie: token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
Perhatikan Secure tidak muncul karena server berjalan dalam mode development. Di produksi (NODE_ENV=production) atribut itu otomatis ikut.
Kesalahan umum
- Menyimpan JWT di
localStorage. Skrip XSS bisa mencurinya. Simpan di cookiehttpOnly+secure+sameSite. - Memakai hash cepat (MD5, SHA-256) untuk password. Itu bukan hashing password. Pakai argon2id atau bcrypt, yang lambat dan memakai salt.
- Mengirim
passwordHashke klien.res.json(user)mengirim semua kolom. Buat function sepertipublicUser()yang memilih field aman saja. - Mengambil
roledari body register. Kalau kamu melakukandb.insert(users).values(req.body), siapa pun bisa mengirim"role": "admin". Ini disebut mass assignment. Zodz.object()membuang field yang tidak didefinisikan, dan role tidak pernah boleh diisi dari input. - Kunci JWT pendek atau ditulis di kode.
JWT_SECRET=rahasiabisa ditebak dengan brute force dalam hitungan menit. Buat string acak minimal 32 byte (node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))") dan simpan di environment variable, bukan di Git.
Latihan
- Mudah, Buat skema Zod
PasswordSchemayang mewajibkan minimal 8 karakter, minimal satu angka, dan minimal satu huruf, masing-masing dengan pesan error Bahasa Indonesia. - Sedang, Tambahkan endpoint
PATCH /api/auth/password(wajib login) yang menerimaoldPassworddannewPassword. Kalau password lama salah, balas 401. Kalau benar, simpan hash baru dan balas 204. - Menantang, Buat alur refresh token: login memasang cookie
access(15 menit, path/) dan cookierefresh(7 hari, path/api/auth/refresh, ditandatangani kunci berbeda). EndpointPOST /api/auth/refreshmemverifikasi refresh token lalu menerbitkan pasangan token baru.
Lihat solusi
1. .regex() bisa dirantai, setiap aturan punya pesannya sendiri.
import { z } from 'zod';
const PasswordSchema = z.string()
.min(8, 'Minimal 8 karakter')
.regex(/[0-9]/, 'Harus ada angka')
.regex(/[a-zA-Z]/, 'Harus ada huruf');
console.log(PasswordSchema.safeParse('abcdefgh').error.issues.map((i) => i.message)); // → [ 'Harus ada angka' ]
console.log(PasswordSchema.safeParse('abc1').error.issues.map((i) => i.message)); // → [ 'Minimal 8 karakter' ]
console.log(PasswordSchema.safeParse('rahasia123').success); // → true
2. Tambahkan di dalam authRouter, sebelum return r:
const ChangePasswordSchema = z.object({
oldPassword: z.string().min(1).max(128),
newPassword: z.string().min(8).max(128),
});
r.patch('/password', auth.requireAuth, validate(ChangePasswordSchema), async (req, res) => {
const user = db.select().from(users).where(eq(users.id, req.user.id)).get();
if (!user || !(await argon2.verify(user.passwordHash, req.body.oldPassword))) {
return res.status(401).json({ error: 'Password lama salah' });
}
const passwordHash = await argon2.hash(req.body.newPassword);
db.update(users).set({ passwordHash }).where(eq(users.id, user.id)).run();
res.status(204).end();
});
// Diuji dengan Supertest:
// password lama salah → 401 { error: 'Password lama salah' }
// password lama benar → 204
// login dengan password lama → 401, dengan password baru → 200
3. Path sempit membuat browser hanya mengirim refresh token ke satu endpoint.
// refresh.js
import express from 'express';
import cookieParser from 'cookie-parser';
import { SignJWT, jwtVerify } from 'jose';
const ACCESS_KEY = new TextEncoder().encode(process.env.ACCESS_SECRET);
const REFRESH_KEY = new TextEncoder().encode(process.env.REFRESH_SECRET); // kunci BEDA
const sign = (sub, key, exp, extra = {}) =>
new SignJWT(extra).setProtectedHeader({ alg: 'HS256' }).setSubject(String(sub))
.setIssuedAt().setExpirationTime(exp).sign(key);
const cookieBase = { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production' };
export async function setTokens(res, user) {
res.cookie('access', await sign(user.id, ACCESS_KEY, '15m', { role: user.role }),
{ ...cookieBase, maxAge: 15 * 60e3, path: '/' });
res.cookie('refresh', await sign(user.id, REFRESH_KEY, '7d'),
{ ...cookieBase, maxAge: 7 * 864e5, path: '/api/auth/refresh' });
}
export function refreshRoute(findUser) {
return async (req, res) => {
try {
const { payload } = await jwtVerify(req.cookies.refresh ?? '', REFRESH_KEY, { algorithms: ['HS256'] });
const user = findUser(Number(payload.sub)); // ambil role TERBARU dari database
if (!user) return res.status(401).json({ error: 'User tidak ada' });
await setTokens(res, user); // rotasi: refresh token lama diganti yang baru
res.json({ ok: true });
} catch {
res.status(401).json({ error: 'Silakan login lagi' });
}
};
}
// app.post('/api/auth/refresh', refreshRoute(findUser));
// Hasil uji:
// cookie login → [' Max-Age=900; Path=/; HttpOnly', ' Max-Age=604800; Path=/api/auth/refresh; HttpOnly']
// refresh dengan cookie → 200 (2 cookie baru), tanpa cookie → 401 { error: 'Silakan login lagi' }
Ringkasan
- Autentikasi menjawab "kamu siapa?". Password disimpan sebagai hash lambat ber-salt (argon2id atau bcrypt), tidak pernah plaintext.
- Login memakai pesan error yang sama untuk email salah dan password salah, dan tetap menjalankan
verifywalau email tidak ada. - Session menyimpan status di server (mudah dicabut). JWT menyimpan data bertanda tangan di token (tidak perlu penyimpanan, sulit dicabut). Payload JWT bisa dibaca siapa saja.
- Simpan token di cookie
httpOnly,secure(produksi), dansameSite: 'lax', bukan dilocalStorage. - Access token pendek + refresh token panjang dengan rotasi adalah kompromi antara keamanan dan kenyamanan.