Authentication #
Authentication adalah proses memverifikasi identitas — memastikan bahwa entitas yang mengklaim sebagai “Ali” memang benar-benar Ali, bukan orang lain yang berpura-pura. Ini adalah gerbang pertama dari semua sistem keamanan: jika authentication lemah, semua lapisan perlindungan lain bisa dibypass.
Yang membuat authentication menarik sekaligus berbahaya: ia bersinggungan langsung dengan pengalaman pengguna. Sistem yang terlalu ketat membuat user frustrasi — terlalu banyak langkah, terlalu sering diminta login ulang, password requirement yang tidak masuk akal. Sistem yang terlalu longgar membuka celah yang dieksploitasi attacker. Menemukan keseimbangan yang tepat antara keamanan dan usability adalah salah satu tantangan terbesar dalam desain sistem.
Artikel ini membahas authentication dari sudut pandang implementasi: bagaimana membangunnya dengan benar dari awal, jebakan-jebakan yang sering menyebabkan insiden, dan praktik terbaik yang sudah teruji.
Authentication vs Authorization: Perbedaan yang Sering Dibalik #
Sebelum membahas implementasi, penting untuk memahami perbedaan dua konsep yang sering digunakan secara bergantian padahal berbeda secara fundamental.
Authentication (AuthN) — "Siapa kamu?"
Proses memverifikasi identitas pengguna.
Input: credential (password, token, biometrik)
Output: identitas yang terverifikasi (atau gagal)
Contoh: login dengan username dan password
Authorization (AuthZ) — "Apa yang boleh kamu lakukan?"
Proses menentukan hak akses berdasarkan identitas.
Input: identitas yang sudah terverifikasi
Output: daftar operasi yang diizinkan
Contoh: user biasa tidak bisa akses halaman admin
Urutan yang benar:
Request masuk → Authentication → Authorization → Process request
Jika authentication gagal → tolak, tidak perlu cek authorization
Jika authorization gagal → tolak dengan 403 Forbidden
Jika keduanya lulus → proses request
Kesalahan yang sering terjadi: melakukan authorization check tanpa authentication check yang benar, atau mengasumsikan bahwa sudah login berarti sudah punya semua akses.
Password: Fondasi yang Sering Salah Diimplementasikan #
Password masih menjadi mekanisme autentikasi yang paling umum — dan masih sering diimplementasikan dengan cara yang salah.
Hashing Password yang Benar #
// ANTI-PATTERN 1: menyimpan password plaintext
user.Password = "userpassword123" // JANGAN PERNAH
// ANTI-PATTERN 2: enkripsi (bukan hashing)
user.Password = encrypt(password, secretKey)
// Enkripsi bisa di-decrypt. Jika secretKey bocor, semua password bocor.
// ANTI-PATTERN 3: hash tanpa salt — rentan rainbow table attack
user.Password = sha256.Sum256([]byte(password))
// Dua user dengan password sama menghasilkan hash sama
// → attacker bisa precompute rainbow table dan langsung match
// ANTI-PATTERN 4: hash cepat — rentan brute force
// SHA256 bisa menghitung miliaran hash per detik di GPU modern
// Database 1 juta password bisa di-crack dalam jam jika hash bocor
// BENAR: gunakan algoritma yang dirancang untuk password hashing.
// golang.org/x/crypto/argon2 — Argon2id (rekomendasi OWASP 2023).
func hashPassword(password string, salt []byte) string {
// time = 3 iterasi — lebih tinggi = lebih lambat = lebih aman
// memory = 65536 KiB (64MB) — mencegah GPU/ASIC attack
// threads = 2, keyLen = 32 byte output; salt random per password
hash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 2, 32)
return fmt.Sprintf("$argon2id$v=19$m=65536,t=3,p=2$%s$%s",
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash))
}
func verifyPassword(stored, provided string, salt []byte) bool {
// Hitung ulang hash dan bandingkan dalam waktu konstan
expected := argon2.IDKey([]byte(provided), salt, 3, 64*1024, 2, 32)
return subtle.ConstantTimeCompare([]byte(stored), expected) == 1
}
func needsRehash(stored string) bool {
// Cek apakah stored hash masih memakai parameter saat ini
return !strings.HasPrefix(stored, "$argon2id$v=19$m=65536,t=3,p=2$")
}
Mengapa Argon2id:
Memory-hard: membutuhkan RAM dalam jumlah besar
→ GPU modern punya ribuan core tapi RAM yang terbatas per core
→ Memory requirement membuat GPU attack jauh lebih mahal
Time-cost: setiap hash butuh ~100-300ms (dikonfigurasi)
→ Server masih bisa handle ratusan login per detik
→ Attacker yang mencoba brute force: hanya bisa mencoba
ratusan/ribuan kombinasi per detik (vs miliaran dengan MD5)
Perbandingan kecepatan hashing (GPU RTX 4090):
MD5: 100+ miliar hash/detik → password 8 char habis dalam detik
SHA256: 10+ miliar hash/detik
bcrypt: ~10 juta hash/detik
Argon2: ~100 ribu hash/detik → miliaran kali lebih lambat dari MD5
Password Policy yang Masuk Akal #
Password policy yang terlalu ketat tidak membuat sistem lebih aman — user akan menulis password di sticky note atau menggunakan pola yang mudah ditebak.
// Password policy berdasarkan NIST SP 800-63B (lebih baru dan lebih baik)
const (
minLength = 12 // panjang minimum, bukan maksimum
maxLength = 128 // ada maksimum untuk mencegah DoS via hash computation
pwnedAPIURL = "https://api.pwnedpasswords.com/range/"
)
func validatePassword(password string) (bool, []string) {
var errors []string
// 1. Panjang minimum
if len(password) < minLength {
errors = append(errors, fmt.Sprintf("Password minimal %d karakter", minLength))
}
// 2. Panjang maksimum
if len(password) > maxLength {
errors = append(errors, fmt.Sprintf("Password maksimal %d karakter", maxLength))
}
// 3. Cek terhadap database password yang diketahui bocor (HaveIBeenPwned API)
if isPasswordPwned(password) {
errors = append(errors,
"Password ini ada dalam daftar password yang diketahui bocor. "+
"Silakan pilih password yang berbeda.")
}
// 4. Cek konteks (jangan gunakan nama aplikasi, username, dll)
// Ini lebih berguna dari requirement "harus ada angka dan huruf besar"
return len(errors) == 0, errors
}
func isPasswordPwned(password string) bool {
// Cek HaveIBeenPwned menggunakan k-Anonymity — password tidak pernah dikirim
sum := sha1.Sum([]byte(password))
hexStr := strings.ToUpper(hex.EncodeToString(sum[:]))
prefix, suffix := hexStr[:5], hexStr[5:]
resp, err := http.Get(pwnedAPIURL + prefix)
if err != nil {
return false // Jika API gagal, jangan blokir user
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return false
}
body, _ := io.ReadAll(resp.Body)
for _, line := range strings.Split(string(body), "\n") {
parts := strings.Split(line, ":")
if len(parts) == 2 && parts[0] == suffix {
count, _ := strconv.Atoi(parts[1])
return count > 0
}
}
return false
}
Apa yang TIDAK perlu (berdasarkan NIST):
✗ Requirement "harus ada huruf besar, angka, dan simbol"
→ Membuat user menggunakan pola yang mudah ditebak: Password1!
✗ Rotasi password wajib secara periodik (misal setiap 90 hari)
→ User menggunakan password lemah yang mudah diingat jika sering diubah
→ Ganti password HANYA jika ada indikasi kompromi
✗ Hints dan security questions
→ Security questions sering bisa ditebak dari info publik
Yang perlu:
✓ Panjang yang cukup (12+ karakter)
✓ Cek terhadap database password yang bocor
✓ Tidak boleh sama dengan username atau email
✓ Tampilkan strength indicator untuk membantu user memilih password kuat
Multi-Factor Authentication (MFA) #
MFA menambahkan lapisan verifikasi kedua setelah password. Bahkan jika password dikompromikan, attacker masih perlu faktor kedua yang tidak mereka miliki.
flowchart TD
A["User masukkan\nusername + password"] --> B{Password benar?}
B --> |Tidak| C["Tolak + log\nfailed attempt"]
B --> |Ya| D{MFA aktif?}
D --> |Tidak| E["Login berhasil\nTapi kurang aman"]
D --> |Ya| F{Jenis MFA?}
F --> G["TOTP\nGoogle Auth/Authy"]
F --> H[SMS OTP]
F --> I["Push notification\nke app"]
F --> J["Hardware key\nYubiKey/FIDO2"]
G --> K{Kode valid?}
H --> K
I --> K
J --> K
K --> |Tidak| L[Tolak + log]
K --> |Ya| M[Login berhasil ✓]TOTP (Time-based One-Time Password) #
// Implementasi TOTP dengan github.com/pquerna/otp
func setupTOTPForUser(userID int, username string) map[string]any {
// Generate secret key yang aman
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "MyApp",
AccountName: username,
})
if err != nil {
return nil
}
// Simpan secret ke database (terenkripsi!)
storeMFASecret(userID, encryptSecret(key.Secret()))
// Generate provisioning URI untuk QR code
provisioningURI := key.URL()
// Generate QR code (github.com/skip2/go-qrcode) dari URI dan
// tampilkan ke user untuk di-scan dengan authenticator app
// Juga tampilkan secret key sebagai teks (untuk manual entry)
return map[string]any{
"secret": key.Secret(), // tampilkan SEKALI, jangan simpan di plain
"provisioning_uri": provisioningURI,
// backup codes untuk recovery
"backup_codes": generateBackupCodes(userID),
}
}
func verifyTOTP(userID int, providedCode string) bool {
secret := decryptSecret(getMFASecret(userID))
// validWindow=1 mengizinkan 1 interval maju/mundur (30 detik)
// untuk mengakomodasi sedikit perbedaan waktu antara server dan device
return totp.Validate(providedCode, secret)
}
func generateBackupCodes(userID int) []string {
// Generate 10 backup codes; simpan hanya hash-nya
codes := make([]string, 0, 10)
for i := 0; i < 10; i++ {
codes = append(codes, randomHex(4))
}
// Simpan hash dari backup codes (bukan plaintext)
// User hanya bisa melihatnya sekali saat setup
storeBackupCodesHashed(userID, codes)
return codes // tampilkan ke user sekali saja
}
Pilihan MFA dari yang paling ke kurang aman:
1. FIDO2/WebAuthn (hardware key atau passkey)
→ Tidak bisa di-phish karena terikat ke domain
→ Paling aman, tapi butuh hardware atau device yang support
2. TOTP (Google Authenticator, Authy)
→ Time-based, kode berbeda setiap 30 detik
→ Rentan phishing jika user ditipu memasukkan kode ke situs palsu
→ Tapi masih jauh lebih baik dari SMS
3. Push notification (Duo, Okta Verify)
→ User approve di app → lebih user-friendly dari TOTP
→ Rentan MFA fatigue attack (spam notifikasi sampai user approve)
4. SMS OTP
→ Rentan SIM swapping attack
→ Lebih baik dari tidak ada MFA sama sekali
→ Hindari untuk aplikasi dengan risiko tinggi (banking, crypto)
Session vs Token-Based Authentication #
Ada dua pendekatan utama untuk mempertahankan state autentikasi setelah login berhasil.
Session-based (stateful):
flowchart LR
Server["Server (Login)"] -->|"store"| DB["Database/Redis<br>session_id<br>user_id: 1<br>expires: .."]
Client["Client<br>Cookie: session=X"] -->|"read/verify"| DB
Server -->|"set-cookie"| ClientKeuntungan: → Session bisa di-revoke kapan saja (hapus dari database) → Server selalu punya info terkini tentang session → Ideal untuk web app tradisional
Kekurangan: → Butuh shared storage jika ada multiple server instance → Tambahan latency untuk lookup ke database/Redis
Token-based (stateless) — JWT:
flowchart LR
Server["Server (Login)"] -->|"sign & send"| Client["Client<br>Header.Payload.Signature"]
Client -->|"send token"| ServerKeuntungan: → Stateless — tidak perlu database lookup untuk verifikasi → Horizontal scaling mudah (tidak perlu shared session store) → Bisa diverifikasi oleh multiple services tanpa komunikasi ke auth server
Kekurangan: → Tidak bisa di-revoke sebelum expire (tanpa additional mechanism) → Payload terbaca oleh siapapun (hanya signature yang tidak bisa dipalsukan)
JWT — Pitfall yang Sering Diabaikan #
// ANTI-PATTERN 1: algoritma "none" — mematikan signature verification
token := jwt.NewWithClaims(jwt.SigningMethodNone, jwt.MapClaims{"user_id": 1})
// Siapapun bisa membuat token tanpa signature
// ANTI-PATTERN 2: kunci HS256 yang lemah
token, _ = jwt.NewWithClaims(jwt.SigningMethodHS256,
jwt.MapClaims{"user_id": 1}).SignedString([]byte("secret"))
// "secret" bisa di-crack dengan wordlist attack dalam detik
// ANTI-PATTERN 3: expiry yang terlalu panjang
claims := jwt.MapClaims{
"user_id": 1,
"exp": time.Now().Add(365 * 24 * time.Hour).Unix(), // 1 tahun!
}
// Token yang dikompromikan valid selama 1 tahun tanpa cara revoke
// BENAR: konfigurasi JWT yang aman (github.com/golang-jwt/jwt/v5)
// HS256: secret key minimal 256-bit (32 byte)
jwtSecret := os.Getenv("JWT_SECRET") // kunci random 32+ byte
if len(jwtSecret) < 32 {
panic("JWT secret terlalu pendek")
}
// Atau RS256 untuk arsitektur multi-service
// RS256 memungkinkan service lain verifikasi token tanpa tahu private key
func createAccessToken(userID int, additionalClaims map[string]any) (string, error) {
now := time.Now()
claims := jwt.MapClaims{
"sub": strconv.Itoa(userID), // subjek
"iat": now.Unix(), // diterbitkan pada
"exp": now.Add(15 * time.Minute).Unix(), // expiry pendek!
"jti": randomToken(16), // ID token unik
"type": "access", // bedakan access vs refresh token
}
for k, v := range additionalClaims {
claims[k] = v
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(jwtSecret))
}
func verifyAccessToken(tokenStr string) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
// eksplisit — jangan terima algoritma "none" atau campuran
if t.Method != jwt.SigningMethodHS256 {
return nil, errors.New("metode signing tidak terduga")
}
return []byte(jwtSecret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return nil, errors.New("token tidak valid")
}
// wajib ada exp, iat, sub, jti, type
if claims["type"] != "access" {
return nil, errors.New("tipe token salah")
}
// Opsional: cek token blacklist jika butuh revocation
if isTokenRevoked(claims["jti"].(string)) {
return nil, errors.New("token sudah di-revoke")
}
return claims, nil
}
Brute Force Protection #
// Rate limiter berbasis Redis (github.com/redis/go-redis/v9)
type RateLimiter struct {
client *redis.Client
maxAttempts int
windowSeconds time.Duration
lockoutSeconds time.Duration
}
func (r *RateLimiter) checkRateLimit(ctx context.Context, identifier string) (bool, int) {
key := "login_attempts:" + identifier
lockoutKey := "login_lockout:" + identifier
// Cek apakah sedang dalam lockout
lockoutRemaining, _ := r.client.TTL(ctx, lockoutKey).Result()
if lockoutRemaining > 0 {
return false, int(lockoutRemaining.Seconds())
}
// Cek jumlah percobaan dalam window
attempts, _ := r.client.Get(ctx, key).Int()
if attempts >= r.maxAttempts {
// Set lockout
r.client.Set(ctx, lockoutKey, 1, r.lockoutSeconds)
r.client.Del(ctx, key)
return false, int(r.lockoutSeconds.Seconds())
}
return true, 0
}
func (r *RateLimiter) recordAttempt(ctx context.Context, identifier string) {
// Record satu attempt
key := "login_attempts:" + identifier
pipe := r.client.Pipeline()
pipe.Incr(ctx, key)
pipe.Expire(ctx, key, r.windowSeconds)
pipe.Exec(ctx)
}
func (r *RateLimiter) resetAttempts(ctx context.Context, identifier string) {
// Reset setelah login berhasil
r.client.Del(ctx, "login_attempts:"+identifier)
r.client.Del(ctx, "login_lockout:"+identifier)
}
// Limiter per IP: 10 percobaan per 5 menit
ipLimiter := &RateLimiter{client: rdb, maxAttempts: 10,
windowSeconds: 5 * time.Minute, lockoutSeconds: 5 * time.Minute}
// Limiter per akun: 5 percobaan per 15 menit (lebih ketat)
accountLimiter := &RateLimiter{client: rdb, maxAttempts: 5,
windowSeconds: 15 * time.Minute, lockoutSeconds: 15 * time.Minute}
// Handler login (net/http)
func loginHandler(w http.ResponseWriter, r *http.Request) {
email := r.FormValue("email")
password := r.FormValue("password")
ip := remoteIP(r)
// Cek rate limit per IP
allowed, retryAfter := ipLimiter.checkRateLimit(r.Context(), ip)
if !allowed {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, `{"error": "Terlalu banyak percobaan. Coba lagi dalam beberapa menit."}`, http.StatusTooManyRequests)
return
}
// Cek rate limit per akun
allowed, retryAfter = accountLimiter.checkRateLimit(r.Context(), email)
if !allowed {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, `{"error": "Akun sementara terkunci karena terlalu banyak percobaan."}`, http.StatusTooManyRequests)
return
}
// Record attempt SEBELUM verifikasi (mencegah timing attack)
ipLimiter.recordAttempt(r.Context(), ip)
accountLimiter.recordAttempt(r.Context(), email)
user := findUserByEmail(email)
// PENTING: selalu verifikasi password (timing-safe)
// Jika user tidak ada, tetap jalankan verify untuk waktu yang konsisten
if user == nil {
verifyPassword(dummyHash, password) // timing dummy
http.Error(w, `{"error": "Email atau password tidak valid"}`, http.StatusUnauthorized)
return
}
if !verifyPassword(user.PasswordHash, password) {
http.Error(w, `{"error": "Email atau password tidak valid"}`, http.StatusUnauthorized)
return
}
// Login berhasil — reset rate limit
ipLimiter.resetAttempts(r.Context(), ip)
accountLimiter.resetAttempts(r.Context(), email)
// Cek apakah hash perlu di-upgrade
if needsRehash(user.PasswordHash) {
user.PasswordHash = hashPassword(password, newSalt())
}
sessionToken := createSession(user.ID, r)
setAuthCookie(w, sessionToken)
w.WriteHeader(http.StatusOK)
}
Secure Password Reset #
Flow reset password adalah salah satu yang paling sering diimplementasikan dengan lemah — tapi berpotensi menjadi celah account takeover yang serius.
// Flow reset password
var resetTokenExpiry = 15 * time.Minute
func requestPasswordReset(email string) {
user := findUserByEmail(email)
// PENTING: response SELALU sama terlepas apakah email ada atau tidak
// Mencegah user enumeration (mengetahui apakah email terdaftar)
if user != nil {
// Generate token yang kuat
rawToken := randomToken(32)
// Simpan HASH dari token (bukan token itu sendiri)
// Jika database bocor, token tidak langsung bisa dipakai
tokenHash := sha256Hex(rawToken)
// Invalidasi semua token reset sebelumnya
deleteResetTokensForUser(user.ID)
createResetToken(ResetTokenRecord{
UserID: user.ID,
TokenHash: tokenHash,
ExpiresAt: time.Now().Add(resetTokenExpiry),
Used: false,
})
// rawToken dikirim ke email, bukan tokenHash
sendResetEmail(user.Email,
"https://app.com/reset-password?token="+rawToken)
}
// Response selalu sama — jangan bedakan "email ada" vs "tidak ada"
_ = jsonResponse(map[string]string{
"message": "Jika email terdaftar, instruksi reset akan dikirimkan.",
})
}
func resetPassword(rawToken, newPassword string) error {
tokenHash := sha256Hex(rawToken)
record := findResetToken(tokenHash, false)
if record == nil {
return errors.New("token tidak valid atau sudah digunakan")
}
if record.ExpiresAt.Before(time.Now()) {
return errors.New("token sudah kedaluwarsa")
}
// Validasi password baru
isValid, errorsList := validatePassword(newPassword)
if !isValid {
return errors.New(strings.Join(errorsList, "; "))
}
// Update password
user := findUserByID(record.UserID)
user.PasswordHash = hashPassword(newPassword)
// Tandai token sebagai sudah digunakan (single-use)
record.Used = true
record.UsedAt = time.Now()
// PENTING: invalidasi semua session aktif setelah reset
// Jika account dikompromikan dan attacker yang set password,
// session attacker juga harus diinvalidasi
invalidateAllSessions(user.ID)
saveResetToken(record)
// Kirim notifikasi email bahwa password berhasil diubah
sendPasswordChangedNotification(user.Email)
return nil
}
Checklist Authentication #
PASSWORD:
□ Password di-hash dengan Argon2id, bcrypt, atau scrypt — bukan MD5/SHA256
□ Salt di-generate otomatis untuk setiap password (Argon2 melakukan ini)
□ Panjang minimum 12 karakter
□ Cek terhadap HaveIBeenPwned API untuk password yang diketahui bocor
□ Tidak memaksa requirement kompleksitas yang tidak perlu (simbol wajib, dll)
□ Tidak ada rotasi password periodik paksa
MFA:
□ MFA tersedia untuk semua user
□ MFA wajib untuk akun admin dan akun dengan privilege tinggi
□ TOTP atau FIDO2 lebih diprioritaskan dari SMS OTP
□ Backup codes tersedia jika device hilang
□ Recovery process yang aman jika user kehilangan akses ke MFA
BRUTE FORCE PROTECTION:
□ Rate limiting per IP dan per akun
□ Account lockout setelah N percobaan gagal
□ Pesan error tidak membedakan "email tidak ada" vs "password salah"
□ Constant-time comparison untuk verifikasi credential
□ Log semua percobaan gagal dengan metadata (IP, timestamp, user agent)
SESSION & TOKEN:
□ Session ID dibuat dengan CSPRNG (secrets.token_urlsafe)
□ JWT menggunakan algoritma eksplisit, expiry pendek, dan jti unique
□ Session di-invalidasi saat logout
□ Session di-invalidasi saat password diubah
□ Idle timeout dan absolute timeout dikonfigurasi
PASSWORD RESET:
□ Token reset dibuat dengan CSPRNG (32+ byte)
□ Token reset di-hash sebelum disimpan di database
□ Token hanya berlaku 15-60 menit
□ Token single-use — langsung invalid setelah dipakai
□ Response reset tidak membocorkan apakah email terdaftar
□ Semua session di-invalidasi setelah reset password
□ Notifikasi email dikirim setelah password berhasil diubah
MONITORING:
□ Failed login attempts di-log dan di-alert jika anomali
□ Login dari lokasi baru/tidak biasa memicu notifikasi
□ Concurrent login dari lokasi yang tidak mungkin dideteksi
Ringkasan #
- Authentication dan authorization adalah dua hal yang berbeda — authn memverifikasi identitas, authz memverifikasi hak akses. Keduanya harus dilakukan secara terpisah dan berurutan.
- Argon2id adalah standar untuk password hashing — memory-hard, time-configurable, dengan salt otomatis. MD5, SHA256, dan enkripsi bukan pilihan yang valid untuk password.
- Policy password yang terlalu ketat justru melemahkan keamanan — fokus pada panjang minimum dan cek terhadap database password yang bocor (HaveIBeenPwned), bukan requirement karakter khusus yang kompleks.
- MFA adalah lapisan perlindungan yang paling efektif terhadap credential compromise — bahkan jika password bocor, attacker masih perlu faktor kedua. FIDO2/WebAuthn adalah yang paling aman karena tidak bisa di-phish.
- Session-based auth memungkinkan revocation, JWT tidak — session bisa di-invalidasi kapan saja. JWT yang dikompromikan valid sampai expire, kecuali ada mekanisme revocation tambahan.
- JWT harus menggunakan algoritma eksplisit dan expiry pendek — jangan izinkan ’none’ algorithm, gunakan HS256/RS256, set access token expire dalam 15 menit.
- Brute force protection harus ada di dua level — per IP dan per akun. Keduanya diperlukan karena attacker bisa datang dari banyak IP atau targetkan satu akun dari satu IP.
- Constant-time comparison untuk verifikasi credential mencegah timing attack —
hmac.compare_digestatausecrets.compare_digest, bukan==operator biasa.- Token reset password harus di-hash sebelum disimpan — sama seperti password, simpan hash-nya bukan token aslinya. Jika database bocor, token tidak langsung bisa dipakai.
- Reset password harus invalidasi semua session aktif — jika akun dikompromikan dan attacker yang memicu reset, session mereka juga harus ikut dihapus.