Ez a réteg mindkét architektúrán a Nitróba kerül — a 9. modul döntésétől függetlenül meg kell csinálni, mert a cookie-kezelésnek ott van a helye, ahol a felhasználó kérése beérkezik. Végigmegyünk egy teljes bejelentkezési folyamaton, a tenant-tagság ellenőrzésén, és megnézzük, mi működik ma (2026-ban) Workers-en a jelszókezelésből — mert ez a rész az elmúlt évben érdemben változott.
A passport + express-session + Redis hármas nem azért nem működik, mert „a Cloudflare furcsa”, hanem mert három konkrét feltételezésre épül, amiből egy sem áll:
| Feltételezés | Mi a valóság Nitro + Workers alatt |
|---|---|
req/res objektumok és middleware-lánc next()-tel | h3-ban egy event van, és a middleware visszatérési értéke a válasz (8. modul) |
| Hosszú életű processz, memóriában tartott session-store | Izolátumok, megosztott modul-scope — a memóriában tartott session szivárog (6. modul) |
| Kimenő TCP-kapcsolat Redishez | Workers-ből nincs nyers TCP tetszőleges szolgáltatáshoz. Van helyette KV, D1, Durable Object — más jellemzőkkel |
A jó hír: a Nuxt-ökoszisztémában van erre kialakult, kényelmes megoldás, és a mentális modell egyszerűbb, mint a régi.
| Stratégia | Hogy működik | Erőssége / gyengéje |
|---|---|---|
| Lezárt (sealed) cookie | A session-adat titkosítva és aláírva magában a cookie-ban utazik. Nincs szerveroldali tároló. | Nulla olvasási költség, nincs külön store. Nem visszavonható önmagában, és mérete korlátos. |
| KV-store | A cookie csak azonosítót tartalmaz, az adat KV-ben van. | Visszavonható, nagy adat is elfér. Végső konzisztencia (~60 mp) — a kijelentkezés nem azonnali mindenhol. |
| Adatbázis | Session-tábla D1-ben vagy Postgresben. | Azonnal konzisztens, kereshető („aktív eszközeim”). Minden kérésnél lekérdezés. |
| Durable Object | Felhasználónként egy objektum tartja a session-t. | Erős konzisztencia, valós idejű kirúgás. Túlzás a legtöbb SaaS-hoz. |
nuxt-auth-utils — a teljes folyamatEz a Nuxt-világ de facto session-megoldása, és pontosan a fenti sealed-cookie mintát valósítja meg.
npx nuxi module add auth-utils
# .env — legalább 32 karakter. Élesben ez SECRET, nem env-változó!
NUXT_SESSION_PASSWORD=egy-legalabb-32-karakteres-veletlen-titok
wrangler.jsonc vars szekciójában — mert a vars tartalma látszik a dashboardon és a konfigban. npx wrangler secret put NUXT_SESSION_PASSWORD. A 12. modulban végigvesszük a runtimeConfig / env / binding / secret négyest.// server/api/auth/login.post.ts
export default defineEventHandler(async (event) => {
const { email, password } = await readValidatedBody(event, loginSchema.parse)
const db = useDb(event)
const user = await db.query.users.findFirst({ where: eq(users.email, email.toLowerCase()) })
// ⚠ ugyanaz az üzenet és nagyjából ugyanaz az idő, létezik-e a felhasználó vagy sem
if (!user || !(await verifyPassword(user.passwordHash, password))) {
throw createError({ statusCode: 401, statusMessage: 'Hibás email vagy jelszó' })
}
const membership = await loadDefaultMembership(db, user.id)
if (!membership) throw createError({ statusCode: 403, statusMessage: 'Nincs aktív munkaterület' })
// a session TARTALMA — csak a minimum, mert minden kérésben utazik
await setUserSession(event, {
user: { id: user.id, name: user.name },
tenantId: membership.tenantId,
role: membership.role,
epoch: user.sessionEpoch, // ← a visszavonáshoz, lásd 10.7
loggedInAt: Date.now(),
})
return { ok: true }
})
<script setup lang="ts">
const { loggedIn, user, clear, fetch: refreshSession } = useUserSession()
async function onSubmit() {
await $fetch('/api/auth/login', { method: 'POST', body: form })
await refreshSession() // a kliensoldali session-állapot frissítése
await navigateTo('/app')
}
</script>
OAuth-hoz kész handlerek vannak (defineOAuthGoogleEventHandler, defineOAuthGitHubEventHandler és több tucat másik), onSuccess callbackkel — abban ugyanúgy setUserSession-t hívsz. Ez az egyik legnagyobb kényelmi különbség a passporthoz képest: nincs strategy-konfiguráció, csak egy handler-fájl.
Ha korábban olvastál Workers-es auth-ról, valószínűleg ezt láttad: „a bcrypt nem működik, használj Web Crypto PBKDF2-t”. Ez ma már árnyaltabb:
| Megoldás | Workers-en | Megjegyzés |
|---|---|---|
bcrypt (npm, natív addon) | Nem működik | C++ bindingokra épül — natív addon soha nem fut a workerd-ben |
bcryptjs (tiszta JS) | Működik, de drága | Lassú, és a Workers CPU-időt számol. Migrációra jó, alapnak nem |
node:crypto scrypt / pbkdf2 | Működik | A nodejs_compat alatt a teljes node:crypto API elérhető (2025 áprilisa óta) |
| Web Crypto PBKDF2 | Működik | Natív, mindig elérhető, nem kell hozzá nodejs_compat |
nuxt-auth-utils hashPassword/verifyPassword | Működik | scryptre épül — a fenti változás miatt ma használható Workers-en is |
Vagyis a legegyszerűbb út ma az, hogy a modul beépített segédfüggvényeit használod:
// regisztráció
const passwordHash = await hashPassword(password)
// bejelentkezés
const ok = await verifyPassword(user.passwordHash, password)
// és ha a hash-paraméterek elavultak, csendben frissítjük sikeres belépéskor
if (ok && passwordNeedsRehash(user.passwordHash)) {
await db.update(users)
.set({ passwordHash: await hashPassword(password) })
.where(eq(users.id, user.id))
}
Ha a mostani Express-appod bcrypttel hasheli a jelszavakat, a hash-eket nem tudod „átalakítani” — csak bejelentkezéskor tudsz újrahashelni, mert csak akkor van meg a nyílt jelszó:
async function checkAndMigrate(db, user, password: string) {
// ① régi formátum? ellenőrizzük bcryptjs-sel (drága, de csak átmenetileg)
if (user.passwordHash.startsWith('$2')) {
const bcrypt = await import('bcryptjs') // dinamikus import: ne nőjön a bundle
if (!(await bcrypt.compare(password, user.passwordHash))) return false
// ② sikeres belépés → átírjuk az új formátumra
await db.update(users)
.set({ passwordHash: await hashPassword(password) })
.where(eq(users.id, user.id))
return true
}
return await verifyPassword(user.passwordHash, password)
}
Itt jön össze a 4., 6. és 8. modul. Három réteg, és mindegyiknek külön dolga van:
// server/utils/auth.ts — minden védett végpont ezzel kezdődik
export async function requireSession(event: H3Event) {
const session = await requireUserSession(event) // 401, ha nincs
return {
userId: session.user.id,
tenantId: session.tenantId as string,
role: session.role as Role,
}
}
// és a tenant-határ: a session tenantja EGYEZZEN az útvonaléval
export async function requireTenant(event: H3Event) {
const s = await requireSession(event)
const routeTenant = event.context.tenant // a 4. modul middleware-éből
if (routeTenant && routeTenant.id !== s.tenantId) {
// NEM 403 — a 404 nem árulja el, hogy létezik-e az a munkaterület
throw createError({ statusCode: 404, statusMessage: 'Nem található' })
}
return s
}
export function requireRole(s: { role: Role }, needed: Role) {
if (!hasAtLeast(s.role, needed)) {
// itt viszont 403 a helyes: a tenanton BELÜL nincs jogosultsága
throw createError({ statusCode: 403, statusMessage: 'Nincs jogosultság' })
}
}
// server/api/projects/[id].delete.ts — így néz ki egy védett végpont
export default defineEventHandler(async (event) => {
const s = await requireTenant(event)
requireRole(s, 'admin')
const deleted = await useDb(event).delete(projects)
.where(and(
eq(projects.id, getRouterParam(event, 'id')!),
eq(projects.tenantId, s.tenantId), // ← a tenant-feltétel MINDIG a lekérdezésben
))
.returning({ id: projects.id })
if (!deleted.length) throw createError({ statusCode: 404 })
setResponseStatus(event, 204)
})
app/middleware/auth.ts route middleware azt éri el, hogy a felhasználó ne lásson olyan képernyőt, amihez nincs joga — kliensen fut, tehát megkerülhető, nem védelem. ② A server/middleware/tenant.ts feloldja a kontextust — nem ellenőriz jogot. ③ A requireTenant() minden egyes végponton — ez a védelem. Ha valahol kimarad, az a végpont védtelen, függetlenül attól, hogy a UI mit mutat.Cookie-alapú session-nél ez nem opcionális. Két lépés, és mindkettő olcsó:
// ① a cookie-beállítás — nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
session: {
name: '__Host-session', // __Host- prefix: csak HTTPS, path=/, nincs domain
cookie: {
sameSite: 'lax', // alapvédelem a keresztoldali POST ellen
secure: true,
httpOnly: true, // JS-ből nem olvasható
path: '/',
},
},
},
})
// ② Origin-ellenőrzés minden mutáló kérésre — server/middleware/3.csrf.ts
const SAFE = new Set(['GET', 'HEAD', 'OPTIONS'])
export default defineEventHandler((event) => {
if (SAFE.has(event.method)) return
if (!event.path.startsWith('/api/')) return
const origin = getHeader(event, 'origin')
const host = getRequestHost(event)
// a böngésző mutáló kérésnél mindig küld Origint
if (!origin || new URL(origin).host !== host) {
throw createError({ statusCode: 403, statusMessage: 'Érvénytelen eredet' })
}
})
SameSite=Lax ugyanis engedi a felső szintű GET-navigációt más oldalról — vagyis egy <img src="/api/projects/42/delete"> típusú támadás pontosan azon a résen jön be. Ha a törlés DELETE vagy POST, ez a vektor eleve nem létezik.A lezárt cookie egyetlen komoly gyengéje: amíg le nem jár, érvényes marad, akkor is, ha közben letiltottad a felhasználót. A „jelszóváltás után jelentkezzen ki mindenhonnan” követelményhez kell egy visszavonási mechanizmus. A legolcsóbb a revocation epoch:
// a users táblában van egy sessionEpoch INTEGER mező (alapból 0)
// jelszóváltás, kirúgás, „kilépés minden eszközön" → epoch++
// server/utils/auth.ts
export async function requireSession(event: H3Event) {
const session = await requireUserSession(event)
// nem minden kérésnél ellenőrzünk — csak ha a session „öreg"
const ageMs = Date.now() - (session.checkedAt ?? session.loggedInAt)
if (ageMs > 5 * 60_000) {
const current = await getUserEpoch(event, session.user.id) // KV vagy DB, cache-elve
if (current !== session.epoch) {
await clearUserSession(event)
throw createError({ statusCode: 401, statusMessage: 'A munkamenet lejárt' })
}
await setUserSession(event, { checkedAt: Date.now() }) // merge, nem csere
}
return { userId: session.user.id, tenantId: session.tenantId, role: session.role }
}
Az ötperces ablak tudatos kompromisszum: a kérések túlnyomó része nulla extra I/O-val megy, a visszavonás pedig legfeljebb öt percen belül érvényesül. Ha ennél szigorúbb kell (például pénzügyi művelet előtt), akkor arra az egy végpontra kényszerítsd ki az azonnali ellenőrzést.
| Csomag | Workers-en | Megjegyzés |
|---|---|---|
nuxt-auth-utils | Igen | Erre épül ez a modul. Sealed cookie, OAuth-handlerek, jelszó-helperek |
better-auth | Igen | Több képesség (szervezetek, 2FA, eszközkezelés) — nagyobb felület, több konfiguráció |
jose | Igen | JWT/JWS/JWE Web Cryptóval — ez kell, ha tokent állítasz ki vagy ellenőrzöl |
jsonwebtoken | Kerüld | Node-crypto-ra épül; jose a natív megfelelője |
passport + strategy-k | Nem | Express-middleware-modellre épül |
express-session, connect-* | Nem | Ugyanaz az ok |
bcrypt (natív) | Nem | Natív addon. bcryptjs megy, de drága — csak migrációra |
Ha B2B SaaS-t építesz, előbb-utóbb jön a „mi Okta/Entra ID-vel akarunk belépni” kérés. Két olcsó döntés most:
setUserSession hívása egy helyen van, és nem az feltételezi, hogy jelszó volt, akkor az SSO később csak egy újabb belépési pont ugyanahhoz.vars), és környezetenként máshttpOnly, secure, sameSite: 'lax', __Host- prefixserver/api végpont requireTenant()-tel kezdődik — nem csak a UI van védveserver/-ből — bindingok, Drizzle D1-gyel és Hyperdrive-val, a kapcsolat életciklusa kérésenként, és a vasszabály arról, mit nem szabad importálni a szerverkódba.express-session + Redis minta, és mi a legkisebb lépés helyette?
Három ok: h3-ban nincs req/res middleware-lánc; a memóriában tartott store az izolátumok között megosztott lenne (adatszivárgás); és Workers-ből nincs nyers TCP Redishez. A legkisebb lépés a lezárt cookie (nuxt-auth-utils): nincs szerveroldali tároló, és a normál kérés nulla extra I/O-t igényel.
Bele: felhasználó- és tenant-azonosító, szerepkör, epoch, időbélyeg. Nem: profil, jogosultsági mátrix, tenant-beállítások. Két ok: a cookie minden kérésben utazik (méret és sávszélesség), és a session tartalma SSR-nél a payloadba is bekerülhet, tehát a felhasználó látja.
A nodejs_compat alatt elérhetővé vált a teljes node:crypto API (2025 áprilisától), tehát a scrypt és a pbkdf2 natívan használható — így a nuxt-auth-utils beépített hashPassword/verifyPassword párosa is működik. A natív bcrypt npm-csomag továbbra sem fut (C++ addon); a bcryptjs igen, de CPU-drága — csak a régi hash-ek migrálására.
① Route middleware a kliensen — csak UX, megkerülhető. ② Szerver middleware a tenant feloldására — kontextust ad, nem ellenőriz jogot. ③ requireTenant() minden végponton — ez a védelem. Ha egy végpontról lemarad, az védtelen, függetlenül attól, hogy a UI mit mutat.
Mert a SameSite=Lax engedi a felső szintű GET-navigációt más oldalról — vagyis egy idegen oldalról indított GET a felhasználó cookie-jával megy. Ha a mutáló műveletek POST/DELETE/PATCH-en vannak, ez a támadási vektor eleve nem létezik; a SameSite és az Origin-ellenőrzés pedig a maradékot fedi le.
Revocation epoch: a felhasználónak van egy számlálója, ami jelszóváltásnál vagy kirúgásnál nő. A session tartalmazza az akkori értéket, és a szerver csak akkor ellenőrzi (KV-ből vagy cache-elt DB-ből), ha a session „öregebb” egy küszöbnél — például öt percnél. Így a kérések többsége nulla extra I/O, a visszavonás pedig legfeljebb öt percen belül érvényesül.