Mengamankan akses API dari nol: hashing password yang benar, sesi berbasis token JWT, flow OAuth2/OIDC untuk integrasi pihak ketiga, model otorisasi RBAC, dan langkah-langkah implementasi autentikasi end-to-end

Setelah di episode 4-6 kalian punya API dan data, sekarang waktunya menjawab dua pertanyaan keamanan paling mendasar: siapa kalian? (authentication) dan apa yang boleh kalian lakukan? (authorization). Dua konsep ini sering dicampur, padahal berbeda dan keduanya wajib.
Mengapa ini krusial? Karena hampir semua kebocoran data besar berawal dari autentikasi yang lemah: password yang di-hash dengan salah, token yang mudah dipalsukan, atau otorisasi yang hanya dicek di frontend. Episode ini membangun autentikasi end-to-end yang benar — fondasi yang akan kalian perkuat di episode 17-19 (security) dan 23 (AI backend, yang juga butuh auth untuk akses model).
Urutannya selalu authentication dulu, baru authorization. API yang melakukan kebalikannya (cek role sebelum cek login) adalah bug keamanan.
Sebelum membahas token, fondasi pertama: password tidak boleh disimpan sebagai teks atau di-hash dengan hash cepat. Gunakan algoritma yang sengaja dibuat lambat (key derivation function) seperti Argon2id atau bcrypt.
import { hash, verify } from "argon2"
export async function hashPassword(plain: string): Promise<string> {
return hash(plain, {
type: "argon2id",
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
})
}
export async function checkPassword(
plain: string,
hashed: string,
): Promise<boolean> {
return verify(hashed, plain)
}Aturan praktis:
Dua pendekatan utama menyimpan state autentikasi:
Server menyimpan sesi di database/Redis, client hanya memegang session_id. Kelebihan: server bisa mencabut sesi kapan saja. Kekurangan: state harus disimpan dan di-sinkronkan antar instance.
import { Redis } from "ioredis"
import { randomBytes } from "node:crypto"
const redis = new Redis(process.env.REDIS_URL!)
export async function createSession(userId: number) {
const sessionId = randomBytes(32).toString("hex")
await redis.set(`session:${sessionId}`, String(userId), "EX", 60 * 60 * 24)
return sessionId
}
export async function getSessionUserId(sessionId: string) {
return redis.get(`session:${sessionId}`)
}JWT adalah token self-contained: payload (misal userId dan role) ditandatangani dengan secret, tanpa disimpan di server. Kelebihan: scalable tanpa state bersama. Kekurangan: token tidak bisa dicabut sebelum kadaluarsa — solusinya TTL pendek + refresh token.
import { SignJWT, jwtVerify } from "jose"
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
export async function signAccessToken(payload: {
userId: number
role: string
}) {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("15m")
.sign(secret)
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, secret)
return payload
}Note
Aturan praktis 2026: JWT untuk API yang stateless dan perlu scale-out, session untuk aplikasi yang butuh revoke instan. Banyak sistem modern memakai keduanya: JWT berumur pendek (15 menit) untuk request, refresh token berumur panjang di storage aman. Jangan pernah memasukkan data sensitif ke payload JWT — payload bisa dibaca siapa saja, hanya tandatangannya yang terproteksi.
import { Router } from "express"
import { hashPassword, checkPassword } from "../security/password.js"
import { signAccessToken } from "../security/jwt.js"
const router = Router()
router.post("/register", async (req, res, next) => {
try {
const { email, password } = req.body
const hashed = await hashPassword(password)
const user = await createUser(email, hashed)
res.status(201).json({ data: { id: user.id, email: user.email } })
} catch (err) {
next(err)
}
})
router.post("/login", async (req, res, next) => {
try {
const { email, password } = req.body
const user = await findUserByEmail(email)
if (!user || !(await checkPassword(password, user.passwordHash))) {
return res.status(401).json({ error: "INVALID_CREDENTIALS" })
}
const token = await signAccessToken({ userId: user.id, role: user.role })
res.json({ data: { token } })
} catch (err) {
next(err)
}
})
export default routerimport type { NextFunction, Request, Response } from "express"
import { verifyAccessToken } from "../security/jwt.js"
export async function requireAuth(
req: Request,
res: Response,
next: NextFunction,
) {
const header = req.headers.authorization
const token = header?.startsWith("Bearer ") ? header.slice(7) : null
if (!token) {
return res.status(401).json({ error: "UNAUTHORIZED", message: "Token diperlukan" })
}
try {
req.user = await verifyAccessToken(token)
next()
} catch {
res.status(401).json({ error: "INVALID_TOKEN", message: "Token tidak valid" })
}
}Token dikirim via header Authorization: Bearer <token> dan diverifikasi di setiap request yang terproteksi.
Setelah autentikasi, authorization menentukan akses. RBAC (Role-Based Access Control) memetakan user ke role, role ke izin.
const ROLE_PERMISSIONS: Record<string, string[]> = {
customer: ["order:create", "order:read:own"],
admin: ["*"],
}
export function requirePermission(permission: string) {
return (req: Request, res: Response, next: NextFunction) => {
const role = req.user?.role ?? "anonymous"
if (!ROLE_PERMISSIONS[role].includes("*") &&
!ROLE_PERMISSIONS[role].includes(permission)) {
return res.status(403).json({ error: "FORBIDDEN", message: "Tidak diizinkan" })
}
next()
}
}Pemakaiannya di route:
router.post("/orders", requireAuth, requirePermission("order:create"), createOrder)Perhatikan lapisan ini: requireAuth menjawab siapa, requirePermission menjawab boleh apa. Keduanya berjalan berurutan.
Saat aplikasi butuh "Login with Google" atau memberi akses ke pihak ketiga, kalian tidak menulis login sendiri — pakai OAuth2/OIDC.
Alur Authorization Code (paling aman untuk web app):
Kunci keamanan: code ditukar di server-side, bukan di browser — access token tidak pernah terekspos ke JavaScript.
MD5/SHA-1/SHA-256 untuk password bisa dipecahkan dengan GPU dalam hitungan detik. Wajib Argon2id/bcrypt.
Token tanpa exp selamanya valid. Selalu set TTL pendek dan sediakan refresh token.
Menyembunyikan tombol "Admin" di UI bukan otorisasi. Semua pemeriksaan izin harus di backend, di tiap route.
Endpoint login yang bisa dicoba jutaan kali adalah pintu brute-force. Episode 20 menutup celah ini.
Warning
Jangan pernah menyimpan JWT_SECRET di kode atau commit ke git — simpan di environment variable/secret manager. Secret yang bocor berarti siapa pun bisa memalsukan token dan mengakses akun apa pun. Episode 17 membahas pengelolaan secrets secara menyeluruh.
Episode 7 membangun autentikasi end-to-end: hashing password dengan Argon2id, dua pendekatan state (session vs JWT), implementasi register/login, middleware requireAuth, RBAC untuk otorisasi, dan alur OAuth2/OIDC.
Inti yang harus dibawa pulang:
Di episode 8 selanjutnya kita akan memastikan semua ini benar-benar bekerja: testing backend — unit test, integration test, contract test, dan end-to-end test, lengkap dengan membangun test suite API. Sampai jumpa di episode 8!