PIN-Protected Dashboard di Next.js: Pattern Autentikasi Sederhana tapi Efektif
Nggak semua dashboard butuh OAuth atau login flow yang ribet. Untuk internal tool yang cuma diakses beberapa orang, PIN protection lebih praktis. Ini implementasi lengkap dari JWT utility sampai client component.
Gue sering nemuin kasus kayak gini: tim butuh dashboard internal, user-nya cuma 3-5 orang di level manajemen, dan mereka eksplisit nggak mau login flow yang panjang. 'Buka URL, masukin PIN, langsung lihat data.'
Untuk use case kayak gini, implementasi full auth (OAuth, username-password, provider login) itu overkill. PIN protection jadi solusi yang lebih praktis tanpa sacrifice security yang memadai.
Gue bakal walk through implementasi lengkapnya — dari JWT utility, API routes, sampai client component.
Struktur File
app/ │ ├── dashboard/ │ │ └── page.tsx │ └── api/ │ └── auth/ │ ├── verify/route.ts │ ├── refresh/route.ts │ └── logout/route.ts lib/ │ └── auth.ts components/ ├── PinGate.tsx └── FinancialDashboard.tsx
Flow-nya Gini
- User buka /dashboard → PinGate component render form PIN
- User masukin PIN → client POST ke /api/auth/verify
- Server validasi PIN, rate limiting, sign JWT → return token di httpOnly cookie
- Client simpan state authenticated → render dashboard
- Setiap 50 menit, client auto-refresh token via /api/auth/refresh
- Kalau token expired → balik ke PIN form
Step 1: JWT Utility dengan jose
Gue pakai jose bukan jsonwebtoken. Kenya? jose itu Edge-compatible — kalau suatu saat lo mau deploy ke Cloudflare Workers atau Vercel Edge, jose langsung jalan.
// lib/auth.ts
import { SignJWT, jwtVerify } from 'jose'const SECRET = new TextEncoder().encode( process.env.JWT_SECRET || 'fallback-secret-change-me' ) const TOKEN_TTL = '1h'
export async function signToken(): Promise<string> { return new SignJWT({ role: 'dashboard' }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime(TOKEN_TTL) .sign(SECRET) }
export async function verifyToken(token: string) { try { const { payload } = await jwtVerify(token, SECRET) return payload } catch { return null // expired atau invalid } } ```
signToken bikin JWT yang expire dalam 1 jam. verifyToken cek validitasnya. Payload-nya minimal — cuma { role: 'dashboard' }.
Step 2: Verify Route dengan Rate Limiting
Ini route yang menangani verifikasi PIN. Yang bikin menarik adalah rate limiting — implementasi manual pakai Map di memory:
// app/api/auth/verify/route.ts
import { NextResponse } from 'next/server'
import { signToken } from '@/lib/auth'const PIN = process.env.DASHBOARD_PIN || '123456' const MAX_ATTEMPTS = 5 const LOCKOUT_MS = 15 * 60 * 1000 // 15 menit
const attempts = new Map<string, { count: number; lockedUntil: number }>()
function getClientId(request: Request): string { const forwarded = request.headers.get('x-forwarded-for') if (forwarded) return forwarded.split(',')[0].trim() return request.headers.get('x-real-ip') ?? 'unknown' }
function getRateState(ip: string) { const entry = attempts.get(ip) if (!entry) return { count: 0, lockedUntil: 0 } if (entry.lockedUntil && Date.now() > entry.lockedUntil) { attempts.delete(ip) return { count: 0, lockedUntil: 0 } } return entry }
export async function POST(request: Request) { const ip = getClientId(request) const state = getRateState(ip)
// Cek lockout
if (state.lockedUntil && Date.now() < state.lockedUntil) {
const remaining = Math.ceil((state.lockedUntil - Date.now()) / 1000)
return NextResponse.json(
{ error: Coba lagi dalam ${remaining} detik., locked: true },
{ status: 429 }
)
}
const { pin } = await request.json()
if (pin !== PIN) { const count = (state.count || 0) + 1 const lockedUntil = count >= MAX_ATTEMPTS ? Date.now() + LOCKOUT_MS : 0 attempts.set(ip, { count, lockedUntil })
if (count >= MAX_ATTEMPTS) {
return NextResponse.json(
{ error: PIN salah ${count}x. Terkunci 15 menit., locked: true },
{ status: 429 }
)
}
return NextResponse.json(
{ error: PIN salah. Sisa ${MAX_ATTEMPTS - count} percobaan. },
{ status: 401 }
)
}
// Success attempts.delete(ip) const token = await signToken()
const res = NextResponse.json({ ok: true }) res.cookies.set('dash_token', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 3600, }) return res } ```
Hal penting:
Rate limiting per IP. 5x salah, kunci 15 menit. In-memory Map cukup untuk internal tool dengan sedikit user.
httpOnly cookie. Token JWT disimpan di cookie yang nggak bisa diakses JavaScript — mencegah XSS.
sameSite: lax. Cookie nggak dikirim di cross-site request — mencegah CSRF.
Step 3: Refresh Route
Token expire 1 jam. Tapi gue nggak mau user harus masukin PIN tiap jam. Solusi: sliding expiration.
// app/api/auth/refresh/route.ts
import { NextResponse } from 'next/server'
import { verifyToken, signToken } from '@/lib/auth'export async function GET(request: Request) { const token = request.cookies.get('dash_token')?.value if (!token) return NextResponse.json({ error: 'No token' }, { status: 401 })
const payload = await verifyToken(token) if (!payload) return NextResponse.json({ error: 'Invalid' }, { status: 401 })
// Token valid — issue token baru const newToken = await signToken() const res = NextResponse.json({ ok: true }) res.cookies.set('dash_token', newToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 3600, }) return res } ```
Setiap kali endpoint ini dipanggil dan token masih valid, server issue token baru. Token selalu di-refresh selama user aktif.
Step 4: Client-Side PinGate Component
Wrapper component yang wrap halaman dashboard:
// components/PinGate.tsx
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Lock } from 'lucide-react'export default function PinGate({ children }: { children: React.ReactNode }) { const [authenticated, setAuthenticated] = useState(false) const [loading, setLoading] = useState(true) const [pin, setPin] = useState('') const [error, setError] = useState('') const inputRef = useRef<HTMLInputElement>(null)
// Cek session yang sudah ada useEffect(() => { fetch('/api/auth/refresh', { method: 'GET' }) .then(res => { if (res.ok) setAuthenticated(true) }) .finally(() => setLoading(false)) }, [])
// Auto refresh setiap 50 menit useEffect(() => { if (!authenticated) return const interval = setInterval(() => { fetch('/api/auth/refresh', { method: 'GET' }).then(res => { if (!res.ok) setAuthenticated(false) }) }, 50 * 60 * 1000) return () => clearInterval(interval) }, [authenticated])
const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault() setError('') const res = await fetch('/api/auth/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pin }), }) if (res.ok) { setAuthenticated(true) setPin('') } else { const data = await res.json() setError(data.error || 'PIN salah') setPin('') inputRef.current?.focus() } }, [pin])
if (loading) return <div className='animate-pulse'>Memeriksa sesi...</div>
if (!authenticated) { return ( <div className='flex items-center justify-center min-h-[60vh]'> <div className='bg-slate-800 rounded-2xl p-8 w-full max-w-sm border border-white/10'> <div className='flex flex-col items-center mb-6'> <Lock className='w-7 h-7 text-blue-400 mb-4' /> <h2 className='text-xl font-bold text-white'>Dashboard Internal</h2> <p className='text-sm text-gray-400 mt-1'>Masukkan PIN untuk akses</p> </div> <form onSubmit={handleSubmit} className='space-y-4'> <input ref={inputRef} type='password' inputMode='numeric' pattern='[0-9]*' maxLength={8} value={pin} onChange={e => { setPin(e.target.value.replace(/\D/g, '')) setError('') }} placeholder='PIN' autoFocus className='w-full px-4 py-3 bg-slate-900 border border-white/10 rounded-lg text-white text-center text-2xl tracking-[0.5em] focus:outline-none focus:border-blue-500' /> {error && <p className='text-red-400 text-sm text-center'>{error}</p>} <button type='submit' className='w-full py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition'> Masuk </button> </form> </div> </div> ) }
return <>{children}</> } ```
Detail UX di input PIN:
- inputMode='numeric' → keyboard mobile otomatis muncul angka
- pattern='[0-9]*' → iOS munculin numpad
- tracking-[0.5em] → karakter PIN terlihat terpisah kayak OTP
- autoFocus → langsung focus, nggak perlu klik
Step 5: Pakai di Halaman Dashboard
Bungkus halaman dashboard dengan PinGate:
// app/dashboard/page.tsx
import PinGate from '@/components/PinGate'
import DashboardContent from '@/components/DashboardContent'export const metadata = { title: 'Dashboard', robots: { index: false, follow: false }, // jangan di-index Google }
export default function DashboardPage() { return ( <PinGate> <DashboardContent /> </PinGate> ) } ```
Satu baris robots: { index: false } itu penting — lo nggak mau dashboard internal muncul di Google.
Kapan Pakai PIN vs Proper Auth
PIN cocok buat: internal dashboard kecil, 3-10 user, data sensitif tapi exposure terbatas, UX speed jadi prioritas.
Proper auth cocok buat: multi-user system, data sangat sensitif, ada permission level yang kompleks, exposure ke internet luas.
Pattern ini nggak menggantikan proper auth — tapi untuk use case yang tepat, ini solusi yang clean dan implementasinya simpel.