Svo znanje
Revokable admin sessions — opaque token + DB row + sha256 hash
protos-web-revokable-admin-sessions·Protos-Web
admin-authsession-managementopaque-tokensha256supabaserlsservice-roleedge-runtimenode-runtime
Revokable admin sessions — opaque token + DB row + sha256 hash
TL;DR
Za admin auth: nikad HMAC(secret) kao cookie value (svi cookie-i su byte-identični, revoke traži rotaciju secret-a). Umjesto toga:
- Cookie value =
randomBytes(32).toString('hex')(64 chars, opaque). - DB row drži
sha256(token)utoken_hashcolumn (UNIQUE + INDEX). verify= query potoken_hash, provjerirevoked_at IS NULL+expires_at > now().- Revoke = set
revoked_at = now()na jedan row (bez utjecaja na ostale).
Kontekst
Kada primijeniti:
- Bilo koji custom admin/user auth gdje ne koristiš gotovi Auth provider (Supabase Auth, Clerk, NextAuth, Auth0).
- Kada treba per-session revoke, IP/UA tracking, "log out all other devices" funkcionalnost.
Kada NE primijeniti:
- Ako Supabase Auth / Clerk / NextAuth već rješava tvoj auth — nemoj duplirati infrastrukturu, koristi njihove server-side revoke API-je.
Trade-off: +1 DB query per admin request. Za low-traffic admin panel (<100 QPS) nevidljivo. Za high-traffic user session — cache-aj u Redis-u sa short TTL.
Snippet
Migracija (Supabase):
CREATE TABLE public.admin_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash text NOT NULL UNIQUE,
ip text NULL,
user_agent text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz NULL
);
CREATE INDEX admin_sessions_token_hash_idx ON public.admin_sessions (token_hash);
ALTER TABLE public.admin_sessions ENABLE ROW LEVEL SECURITY;
-- Deliberately NO policies. service_role bypasses RLS.
Create session (Node runtime, server action):
import 'server-only'
import { randomBytes, createHash } from 'node:crypto'
const TTL_MS = 60 * 60 * 24 * 7 * 1000 // 7 days
const token = randomBytes(32).toString('hex')
const tokenHash = createHash('sha256').update(token).digest('hex')
const expiresAt = new Date(Date.now() + TTL_MS)
const { data } = await supabaseAdmin
.from('admin_sessions')
.insert({ token_hash: tokenHash, ip, user_agent: ua, expires_at: expiresAt.toISOString() })
.select('id')
.single()
// Set as httpOnly + Secure + SameSite=Lax cookie sa `expires: expiresAt`.
cookies().set('admin_session', token, { httpOnly: true, secure: true, sameSite: 'lax', expires: expiresAt })
Verify (Node runtime):
export async function verifySessionToken(token: string | null): Promise<Row | null> {
if (!token) return null
const tokenHash = createHash('sha256').update(token).digest('hex')
const { data } = await supabaseAdmin
.from('admin_sessions').select('*').eq('token_hash', tokenHash).single()
if (!data || data.revoked_at || new Date(data.expires_at).getTime() <= Date.now()) return null
// Timing-safe re-check (defensive; index equality is already O(1) but a
// downstream refactor could accidentally introduce partial-match logic).
const a = Buffer.from(data.token_hash), b = Buffer.from(tokenHash)
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null
// Fire-and-forget last_seen_at bump — never await.
supabaseAdmin.from('admin_sessions').update({ last_seen_at: new Date().toISOString() })
.eq('id', data.id).then(() => {}, () => {})
return data
}
Verify (Edge middleware, WebCrypto + REST):
async function sha256Hex(input: string): Promise<string> {
const buf = new TextEncoder().encode(input)
const digest = await crypto.subtle.digest('SHA-256', buf)
return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('')
}
export async function verifyAdminSessionEdge(token: string): Promise<boolean> {
const url = process.env.SUPABASE_URL!
const svc = process.env.SUPABASE_SERVICE_ROLE_KEY!
const tokenHash = await sha256Hex(token)
const res = await fetch(`${url}/rest/v1/admin_sessions?token_hash=eq.${tokenHash}&select=revoked_at,expires_at`, {
headers: { apikey: svc, authorization: `Bearer ${svc}`, accept: 'application/json' },
cache: 'no-store',
})
if (!res.ok) return false
const rows = await res.json()
const row = rows[0]
return row && !row.revoked_at && new Date(row.expires_at).getTime() > Date.now()
}
Gotchas
- Cookie value plaintext, DB hash-only — nikad ne pohranjuj plaintext u DB, i nikad ne pohranjuj samo hash u cookie (klijent ne bi znao regenerirati). Ovo je isti pattern kao password hashing.
sha256je OK za opaque token verify — nije potreban bcrypt/argon2 jer token ima 256 bita entropije (randomBytes(32)), brute-force je matematički nemoguć. bcrypt bi bio potreban samo ako je token user-choosen (npr. lozinka).- Fire-and-forget
last_seen_atupdate — inače dodaješ ~50ms per request..then(() => {}, () => {})da consumer promise da ga event loop ne kicka kao unhandled rejection. - Edge runtime nema
node:crypto— WebCrypto SHA-256 + direct REST fetch (ne@supabase/supabase-js) jer je bundle bloat. - RLS enabled + zero policies — service_role bypass-a RLS, tako da server code radi normalno. Anon/authenticated blokirani na tabelu i bez policy statement-a.
Vidi također
- memory/sessions/2026-07-22-03-security-hardening-pr-45.md
src/lib/auth/admin-sessions.ts(implementacija)src/lib/auth/admin-auth-shared.ts(edge verify)