Moderní autentizace v roce 2026: JWT, sessions, OAuth a passkeys
Kompletní přehled autentizace: kdy použít sessions vs JWT, OAuth 2.0 / OIDC flow, rotace refresh tokenů, passkeys (WebAuthn) a Next.js auth patterny, které skutečně používám.
Autentizace je ta jedna oblast webového vývoje, kde „funguje to" nikdy nestačí. Chyba ve vašem date pickeru je otravná. Chyba ve vašem auth systému je únik dat.
Implementoval jsem autentizaci od nuly, migroval mezi poskytovateli, ladil incidenty krádeže tokenů a řešil následky rozhodnutí „bezpečnost opravíme později". Tento příspěvek je kompletní průvodce, který bych si přál mít, když jsem začínal. Ne jen teorie — skutečné kompromisy, reálné zranitelnosti a vzory, které obstojí pod produkčním tlakem.
Projdeme celý přehled: sessions, JWT, OAuth 2.0, passkeys, MFA a autorizaci. Na konci pochopíte nejen jak každý mechanismus funguje, ale kdy ho použít a proč alternativy existují.
Sessions vs JWT: Skutečné kompromisy#
Toto je první rozhodnutí, se kterým se setkáte, a internet je plný špatných rad. Dovolte mi rozebrat, na čem skutečně záleží.
Autentizace založená na sessions#
Sessions jsou původní přístup. Server vytvoří záznam session, uloží ho někam (databáze, Redis, paměť) a dá klientovi neprůhledné ID session v cookie.
// Zjednodušené vytváření session
import { randomBytes } from "crypto";
import { cookies } from "next/headers";
interface Session {
userId: string;
createdAt: Date;
expiresAt: Date;
ipAddress: string;
userAgent: string;
}
async function createSession(userId: string, request: Request): Promise<string> {
const sessionId = randomBytes(32).toString("hex");
const session: Session = {
userId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hodin
ipAddress: request.headers.get("x-forwarded-for") ?? "unknown",
userAgent: request.headers.get("user-agent") ?? "unknown",
};
// Uložení do databáze nebo Redisu
await redis.set(`session:${sessionId}`, JSON.stringify(session), "EX", 86400);
const cookieStore = await cookies();
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 86400,
path: "/",
});
return sessionId;
}Výhody jsou skutečné:
- Okamžité zneplatnění. Smažte záznam session a uživatel je odhlášen. Žádné čekání na expiraci. To je důležité, když detekujete podezřelou aktivitu.
- Viditelnost sessions. Můžete uživatelům ukázat jejich aktivní sessions („přihlášen na Chrome, Windows 11, Istanbul") a nechat je zneplatnit jednotlivé.
- Malá velikost cookie. ID session je typicky 64 znaků. Cookie nikdy neroste.
- Serverová kontrola. Můžete aktualizovat data session (povýšit uživatele na admina, změnit oprávnění) a změna se projeví při dalším požadavku.
Nevýhody jsou také skutečné:
- Dotaz do databáze při každém požadavku. Každý autentizovaný požadavek potřebuje vyhledání session. S Redisem je to pod milisekundu, ale stále je to závislost.
- Horizontální škálování vyžaduje sdílené úložiště. Pokud máte více serverů, všechny potřebují přístup ke stejnému úložišti sessions. Sticky sessions jsou křehké řešení.
- CSRF je problém. Protože cookies se odesílají automaticky, potřebujete ochranu proti CSRF. SameSite cookies to z velké části řeší, ale musíte pochopit proč.
Autentizace založená na JWT#
JWT převrací model. Místo ukládání stavu session na serveru ho zakódujete do podepsaného tokenu, který drží klient.
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
async function createAccessToken(userId: string, role: string): Promise<string> {
return new SignJWT({ sub: userId, role })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.setIssuer("https://akousa.net")
.setAudience("https://akousa.net")
.sign(secret);
}
async function verifyAccessToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret, {
issuer: "https://akousa.net",
audience: "https://akousa.net",
});
return payload;
} catch {
return null;
}
}Výhody:
- Žádné serverové úložiště. Token je soběstačný. Ověříte podpis a přečtete claims. Žádný dotaz do databáze.
- Funguje napříč službami. V architektuře mikroslužeb může jakákoliv služba s veřejným klíčem ověřit token. Není potřeba sdílené úložiště sessions.
- Bezstavové škálování. Přidejte více serverů bez starostí o afinitu sessions.
Nevýhody — a tyto lidé přehlížejí:
- JWT nemůžete zneplatnit. Jakmile je vydaný, je platný, dokud nevyprší. Pokud je účet uživatele kompromitován, nemůžete vynutit odhlášení. Můžete vytvořit blocklist, ale pak jste znovu zavedli serverový stav a ztratili hlavní výhodu.
- Velikost tokenu. JWT s několika claims mají typicky 800+ bajtů. Přidejte role, oprávnění a metadata a posíláte kilobajty při každém požadavku.
- Payload je čitelný. Payload je Base64-kódovaný, ne šifrovaný. Kdokoliv ho může dekódovat. Nikdy nevkládejte citlivá data do JWT.
- Problémy s odchylkou hodin. Pokud mají vaše servery různé hodiny (stává se), kontroly expirace se stávají nespolehlivými.
Kdy použít který#
Mé pravidlo:
Použijte sessions, když: Máte monolitickou aplikaci, potřebujete okamžité zneplatnění, budujete produkt pro spotřebitele, kde je zabezpečení účtu kritické, nebo se vaše požadavky na auth mohou často měnit.
Použijte JWT, když: Máte architekturu mikroslužeb, kde služby potřebují nezávisle ověřovat identitu, budujete komunikaci API-to-API, nebo implementujete autentizační systém třetích stran.
V praxi: Většina aplikací by měla používat sessions. Argument „JWT jsou škálovatelnější" platí pouze tehdy, pokud skutečně máte problém se škálováním, který úložiště sessions nedokáže vyřešit — a Redis zvládá miliony vyhledávání sessions za sekundu. Viděl jsem příliš mnoho projektů, které si vybraly JWT, protože zní moderněji, a pak vybudovaly blocklist a systém refresh tokenů, který je složitější, než by byly sessions.
JWT do hloubky#
I když si vyberete autentizaci založenou na sessions, setkáte se s JWT prostřednictvím OAuth, OIDC a integrací třetích stran. Porozumění vnitřnostem je nezbytné.
Anatomie JWT#
JWT má tři části oddělené tečkami: header.payload.signature
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...
Header — deklaruje algoritmus a typ tokenu:
{
"alg": "RS256",
"typ": "JWT"
}Payload — obsahuje claims. Standardní claims mají krátká jména:
{
"sub": "user_123", // Subject (o kom to je)
"iss": "https://auth.example.com", // Issuer (kdo to vytvořil)
"aud": "https://api.example.com", // Audience (kdo by to měl akceptovat)
"iat": 1709312000, // Issued At (Unix timestamp)
"exp": 1709312900, // Expiration (Unix timestamp)
"role": "admin" // Vlastní claim
}Signature — dokazuje, že s tokenem nebylo manipulováno. Vytvoří se podepsáním zakódovaného headeru a payloadu tajným klíčem.
RS256 vs HS256: Na tomhle skutečně záleží#
HS256 (HMAC-SHA256) — symetrický. Stejný secret podepisuje i ověřuje. Jednoduché, ale každá služba, která potřebuje ověřovat tokeny, musí mít secret. Pokud je jakákoli z nich kompromitována, útočník může padělat tokeny.
RS256 (RSA-SHA256) — asymetrický. Privátní klíč podepisuje, veřejný klíč ověřuje. Pouze auth server potřebuje privátní klíč. Jakákoli služba může ověřovat veřejným klíčem. Pokud je ověřovací služba kompromitována, útočník může číst tokeny, ale nemůže je padělat.
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
// RS256 — použijte, když více služeb ověřuje tokeny
const privateKeyPem = process.env.JWT_PRIVATE_KEY!;
const publicKeyPem = process.env.JWT_PUBLIC_KEY!;
async function signWithRS256(payload: Record<string, unknown>) {
const privateKey = await importPKCS8(privateKeyPem, "RS256");
return new SignJWT(payload)
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(privateKey);
}
async function verifyWithRS256(token: string) {
const publicKey = await importSPKI(publicKeyPem, "RS256");
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ["RS256"], // KRITICKÉ: vždy omezte algoritmy
});
return payload;
}Pravidlo: Použijte RS256 kdykoli tokeny překračují hranice služeb. Použijte HS256 pouze tehdy, když stejná služba podepisuje i ověřuje.
Útok alg: none#
Toto je nejslavnější zranitelnost JWT a je trapně jednoduchá. Některé JWT knihovny dříve:
- Přečetly pole
algz headeru - Použily jakýkoliv algoritmus, který uvedl
- Pokud
alg: "none", přeskočily ověření podpisu úplně
Útočník mohl vzít platný JWT, změnit payload (např. nastavit "role": "admin"), nastavit alg na "none", odstranit podpis a poslat ho. Server ho akceptoval.
// ZRANITELNÉ — nikdy to nedělejte
function verifyJwt(token: string) {
const [headerB64, payloadB64, signature] = token.split(".");
const header = JSON.parse(atob(headerB64));
if (header.alg === "none") {
// "Podpis není potřeba" — KATASTROFÁLNÍ
return JSON.parse(atob(payloadB64));
}
// ... ověření podpisu
}Oprava je jednoduchá: vždy specifikujte očekávaný algoritmus explicitně. Nikdy nenechte token říkat vám, jak ho ověřit.
// BEZPEČNÉ — algoritmus je napevno, nečte se z tokenu
const { payload } = await jwtVerify(token, key, {
algorithms: ["RS256"], // Akceptovat pouze RS256 — ignorovat header
});Moderní knihovny jako jose to ve výchozím nastavení řeší správně, ale stále byste měli explicitně předat možnost algorithms jako obranu do hloubky.
Útok záměnou algoritmu#
Souvisí s výše uvedeným: pokud je server nakonfigurován pro přijímání RS256, útočník může:
- Získat veřejný klíč serveru (je veřejný, koneckonců)
- Vytvořit token s
alg: "HS256" - Podepsat ho pomocí veřejného klíče jako HMAC secret
Pokud server přečte header alg a přepne na ověření HS256, veřejný klíč (který každý zná) se stane sdíleným secretem. Podpis je platný. Útočník padělal token.
Opět, oprava je stejná: nikdy nevěřte algoritmu z headeru tokenu. Vždy ho napevno zakódujte.
Rotace Refresh tokenů#
Pokud používáte JWT, potřebujete strategii refresh tokenů. Posílání dlouhodobého access tokenu si říká o problémy — pokud je ukraden, útočník má přístup po celou dobu životnosti.
Vzor:
- Access token: krátkodobý (15 minut). Používá se pro API požadavky.
- Refresh token: dlouhodobý (30 dní). Používá se pouze k získání nového access tokenu.
import { randomBytes } from "crypto";
interface RefreshTokenRecord {
tokenHash: string;
userId: string;
familyId: string; // Seskupuje související tokeny dohromady
used: boolean;
expiresAt: Date;
createdAt: Date;
}
async function issueTokenPair(userId: string) {
const familyId = randomBytes(16).toString("hex");
const accessToken = await createAccessToken(userId);
const refreshToken = randomBytes(64).toString("hex");
const refreshTokenHash = await hashToken(refreshToken);
// Uložení záznamu refresh tokenu
await db.refreshToken.create({
data: {
tokenHash: refreshTokenHash,
userId,
familyId,
used: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
},
});
return { accessToken, refreshToken };
}Rotace při každém použití#
Pokaždé, když klient použije refresh token k získání nového access tokenu, vydáte nový refresh token a zneplatníte starý:
async function rotateTokens(incomingRefreshToken: string) {
const tokenHash = await hashToken(incomingRefreshToken);
const record = await db.refreshToken.findUnique({
where: { tokenHash },
});
if (!record) {
// Token neexistuje — možná krádež
return null;
}
if (record.expiresAt < new Date()) {
// Token expiroval
await db.refreshToken.delete({ where: { tokenHash } });
return null;
}
if (record.used) {
// TENTO TOKEN JIŽ BYL POUŽIT.
// Někdo ho přehrává — buď legitimní uživatel,
// nebo útočník. V obou případech zabijte celou rodinu.
await db.refreshToken.deleteMany({
where: { familyId: record.familyId },
});
console.error(
`Refresh token reuse detected for user ${record.userId}, family ${record.familyId}. All tokens in family invalidated.`
);
return null;
}
// Označení aktuálního tokenu jako použitého (nemažeme — potřebujeme ho pro detekci opětovného použití)
await db.refreshToken.update({
where: { tokenHash },
data: { used: true },
});
// Vydání nového páru se stejným ID rodiny
const newRefreshToken = randomBytes(64).toString("hex");
const newRefreshTokenHash = await hashToken(newRefreshToken);
await db.refreshToken.create({
data: {
tokenHash: newRefreshTokenHash,
userId: record.userId,
familyId: record.familyId, // Stejná rodina
used: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
},
});
const newAccessToken = await createAccessToken(record.userId);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}Proč je zneplatnění rodiny důležité#
Zvažte tento scénář:
- Uživatel se přihlásí, dostane refresh token A
- Útočník ukradne refresh token A
- Útočník použije A k získání nového páru (access token + refresh token B)
- Uživatel zkusí použít A (který stále má) k obnovení
Bez detekce opětovného použití uživatel jen dostane chybu. Útočník pokračuje s tokenem B. Uživatel se přihlásí znovu, aniž by věděl, že jeho účet byl kompromitován.
S detekcí opětovného použití a zneplatněním rodiny: když uživatel zkusí použít již použitý token A, systém detekuje opětovné použití, zneplatní každý token v rodině (včetně B) a vynutí opětovnou autentizaci obou — uživatele i útočníka. Uživatel dostane výzvu „přihlaste se znovu" a může si uvědomit, že něco není v pořádku.
Toto je přístup používaný Auth0, Okta a Auth.js. Není dokonalý — pokud útočník použije token dříve než legitimní uživatel, legitimní uživatel se stane tím, kdo spustí upozornění na opětovné použití. Ale je to to nejlepší, co můžeme udělat s bearer tokeny.
OAuth 2.0 a OIDC#
OAuth 2.0 a OpenID Connect jsou protokoly za „Přihlásit se přes Google/GitHub/Apple." Jejich pochopení je nezbytné i pokud používáte knihovnu, protože když se věci rozbijí — a rozbijí se — musíte vědět, co se děje na úrovni protokolu.
Klíčový rozdíl#
OAuth 2.0 je autorizační protokol. Odpovídá na: „Může tato aplikace přistoupit k datům tohoto uživatele?" Výsledkem je access token, který uděluje specifická oprávnění (scopy).
OpenID Connect (OIDC) je autentizační vrstva postavená na OAuth 2.0. Odpovídá na: „Kdo je tento uživatel?" Výsledkem je ID token (JWT), který obsahuje informace o identitě uživatele.
Když se „Přihlásíte přes Google," používáte OIDC. Google řekne vaší aplikaci, kdo je uživatel (autentizace). Můžete také požádat o OAuth scopy pro přístup k jejich kalendáři nebo disku (autorizace).
Authorization Code Flow s PKCE#
Toto je flow, které byste měli použít pro webové aplikace. PKCE (Proof Key for Code Exchange) byl původně navržen pro mobilní aplikace, ale nyní je doporučen pro všechny klienty, včetně serverových aplikací.
import { randomBytes, createHash } from "crypto";
// Krok 1: Vygenerování PKCE hodnot a přesměrování uživatele
function initiateOAuthFlow() {
// Code verifier: náhodný řetězec 43-128 znaků
const codeVerifier = randomBytes(32)
.toString("base64url")
.slice(0, 43);
// Code challenge: SHA256 hash verifieru, base64url-kódovaný
const codeChallenge = createHash("sha256")
.update(codeVerifier)
.digest("base64url");
// State: náhodná hodnota pro ochranu proti CSRF
const state = randomBytes(16).toString("hex");
// Uložení obou do session (na serveru!) před přesměrováním
// NIKDY nedávejte code_verifier do cookie nebo URL parametru
session.codeVerifier = codeVerifier;
session.oauthState = state;
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!);
authUrl.searchParams.set("redirect_uri", "https://example.com/api/auth/callback/google");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", "openid email profile");
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
return authUrl.toString();
}// Krok 2: Zpracování callbacku
async function handleOAuthCallback(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
// Kontrola chyb od poskytovatele
if (error) {
throw new Error(`OAuth error: ${error}`);
}
// Ověření, že state odpovídá (ochrana CSRF)
if (state !== session.oauthState) {
throw new Error("State mismatch — possible CSRF attack");
}
// Výměna autorizačního kódu za tokeny
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: code!,
redirect_uri: "https://example.com/api/auth/callback/google",
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
code_verifier: session.codeVerifier, // PKCE: dokazuje, že jsme tento flow zahájili
}),
});
const tokens = await tokenResponse.json();
// tokens.access_token — pro API volání na Google
// tokens.id_token — JWT s identitou uživatele (OIDC)
// tokens.refresh_token — pro získání nových access tokenů
// Krok 3: Ověření ID tokenu a extrakce informací o uživateli
const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
return {
googleId: idTokenPayload.sub,
email: idTokenPayload.email,
name: idTokenPayload.name,
picture: idTokenPayload.picture,
};
}Tři endpointy#
Každý OAuth/OIDC poskytovatel vystavuje tyto:
- Authorization endpoint — kam přesměrujete uživatele k přihlášení a udělení oprávnění. Vrací autorizační kód.
- Token endpoint — kde váš server vymění autorizační kód za access/refresh/ID tokeny. Toto je server-to-server volání.
- UserInfo endpoint — kde můžete načíst další profilová data uživatele pomocí access tokenu. S OIDC je většina z toho již v ID tokenu.
Parametr State#
Parametr state brání CSRF útokům na OAuth callback. Bez něj:
- Útočník zahájí OAuth flow na svém vlastním stroji, dostane autorizační kód
- Útočník vytvoří URL:
https://yourapp.com/callback?code=ATTACKER_CODE - Útočník přiměje oběť kliknout na něj (emailový odkaz, skrytý obrázek)
- Vaše aplikace vymění kód útočníka a propojí účet útočníka na Googlu se session oběti
S state: vaše aplikace vygeneruje náhodnou hodnotu, uloží ji do session a zahrne ji v autorizačním URL. Když přijde callback, ověříte, že state odpovídá. Útočník toto nemůže padělat, protože nemá přístup k session oběti.
Auth.js (NextAuth) s Next.js App Router#
Auth.js je to, po čem sáhnu jako první ve většině Next.js projektů. Řeší OAuth tanec, správu sessions, perzistenci do databáze a ochranu CSRF. Zde je nastavení připravené na produkci.
Základní konfigurace#
// src/lib/auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
// Použití databázových sessions (ne JWT) pro lepší bezpečnost
session: {
strategy: "database",
maxAge: 30 * 24 * 60 * 60, // 30 dní
updateAge: 24 * 60 * 60, // Prodloužení session každých 24 hodin
},
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
// Požadavek na specifické scopy
authorization: {
params: {
scope: "openid email profile",
prompt: "consent",
access_type: "offline", // Získání refresh tokenu
},
},
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
// Přihlášení emailem/heslem (používejte opatrně)
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.passwordHash) {
return null;
}
const isValid = await verifyPassword(
credentials.password as string,
user.passwordHash
);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
};
},
}),
],
callbacks: {
// Kontrola, kdo se může přihlásit
async signIn({ user, account }) {
// Blokování přihlášení pro zabanované uživatele
if (user.id) {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { banned: true },
});
if (dbUser?.banned) return false;
}
return true;
},
// Přidání vlastních polí do session
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
// Načtení role z databáze
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { role: true },
});
session.user.role = dbUser?.role ?? "user";
}
return session;
},
},
pages: {
signIn: "/login",
error: "/auth/error",
verifyRequest: "/auth/verify",
},
});Route Handler#
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;Ochrana middleware#
// src/middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAuthPage = req.nextUrl.pathname.startsWith("/login")
|| req.nextUrl.pathname.startsWith("/register");
const isProtectedRoute = req.nextUrl.pathname.startsWith("/dashboard")
|| req.nextUrl.pathname.startsWith("/settings")
|| req.nextUrl.pathname.startsWith("/admin");
const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");
// Přesměrování přihlášených uživatelů pryč z auth stránek
if (isLoggedIn && isAuthPage) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
// Přesměrování neautentizovaných uživatelů na přihlášení
if (!isLoggedIn && isProtectedRoute) {
const callbackUrl = encodeURIComponent(req.nextUrl.pathname);
return NextResponse.redirect(
new URL(`/login?callbackUrl=${callbackUrl}`, req.nextUrl)
);
}
// Kontrola admin role
if (isAdminRoute && req.auth?.user?.role !== "admin") {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: [
"/dashboard/:path*",
"/settings/:path*",
"/admin/:path*",
"/login",
"/register",
],
};Použití session v Server komponentách#
// src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return (
<div>
<h1>Welcome, {session.user.name}</h1>
<p>Role: {session.user.role}</p>
</div>
);
}Použití session v Client komponentách#
"use client";
import { useSession } from "next-auth/react";
export function UserMenu() {
const { data: session, status } = useSession();
if (status === "loading") {
return <div>Loading...</div>;
}
if (status === "unauthenticated") {
return <a href="/login">Sign In</a>;
}
return (
<div>
<img
src={session?.user?.image ?? "/default-avatar.png"}
alt={session?.user?.name ?? "User"}
/>
<span>{session?.user?.name}</span>
</div>
);
}Passkeys (WebAuthn)#
Passkeys jsou nejvýznamnější vylepšení autentizace za poslední roky. Jsou odolné vůči phishingu, odolné vůči opakovanému přehrání a eliminují celou kategorii zranitelností souvisejících s hesly. Pokud začínáte nový projekt v roce 2026, měli byste passkeys podporovat.
Jak passkeys fungují#
Passkeys používají kryptografii s veřejným klíčem, podporovanou biometrikou nebo PIN kódy zařízení:
- Registrace: Prohlížeč vygeneruje pár klíčů. Privátní klíč zůstane na zařízení (v zabezpečeném enklávu, chráněném biometrikou). Veřejný klíč je odeslán na váš server.
- Autentizace: Server pošle výzvu (náhodné bajty). Zařízení podepíše výzvu privátním klíčem (po biometrickém ověření). Server ověří podpis uloženým veřejným klíčem.
Žádný sdílený secret nikdy nepřejde přes síť. Není co phishovat, co uniknout, co zkoušet.
Proč jsou passkeys odolné vůči phishingu#
Když je passkey vytvořen, je vázán na origin (např. https://example.com). Prohlížeč použije passkey pouze na přesném originu, pro který byl vytvořen. Pokud útočník vytvoří podobně vypadající web na https://exarnple.com, passkey jednoduše nebude nabídnut. Toto je vynuceno prohlížečem, ne bdělostí uživatele.
To je zásadně odlišné od hesel, kde uživatelé běžně zadávají své přihlašovací údaje na phishingových webech, protože stránka vypadá správně.
Implementace se SimpleWebAuthn#
SimpleWebAuthn je knihovna, kterou doporučuji. Správně zpracovává protokol WebAuthn a má dobré TypeScript typy.
// Serverová strana: Registrace
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
GenerateRegistrationOptionsOpts,
VerifiedRegistrationResponse,
} from "@simplewebauthn/server";
const rpName = "akousa.net";
const rpID = "akousa.net";
const origin = "https://akousa.net";
async function startRegistration(userId: string, userEmail: string) {
// Získání existujících passkeys uživatele pro vyloučení
const existingCredentials = await db.credential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
const options: GenerateRegistrationOptionsOpts = {
rpName,
rpID,
userID: new TextEncoder().encode(userId),
userName: userEmail,
attestationType: "none", // Attestaci pro většinu aplikací nepotřebujeme
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
};
const registrationOptions = await generateRegistrationOptions(options);
// Dočasné uložení výzvy — potřebujeme ji pro ověření
await redis.set(
`webauthn:challenge:${userId}`,
registrationOptions.challenge,
"EX",
300 // 5 minut expirace
);
return registrationOptions;
}
async function finishRegistration(userId: string, response: unknown) {
const expectedChallenge = await redis.get(`webauthn:challenge:${userId}`);
if (!expectedChallenge) {
throw new Error("Challenge expired or not found");
}
let verification: VerifiedRegistrationResponse;
try {
verification = await verifyRegistrationResponse({
response: response as any,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
} catch (error) {
throw new Error(`Registration verification failed: ${error}`);
}
if (!verification.verified || !verification.registrationInfo) {
throw new Error("Registration verification failed");
}
const { credential } = verification.registrationInfo;
// Uložení credential do databáze
await db.credential.create({
data: {
userId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: credential.transports ?? [],
},
});
// Úklid
await redis.del(`webauthn:challenge:${userId}`);
return { verified: true };
}// Serverová strana: Autentizace
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
async function startAuthentication(userId?: string) {
let allowCredentials;
// Pokud známe uživatele (např. zadal email), omezíme na jeho passkeys
if (userId) {
const credentials = await db.credential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
allowCredentials = credentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
}));
}
const options = await generateAuthenticationOptions({
rpID,
allowCredentials,
userVerification: "preferred",
});
// Uložení výzvy pro ověření
const challengeKey = userId
? `webauthn:auth:${userId}`
: `webauthn:auth:${options.challenge}`;
await redis.set(challengeKey, options.challenge, "EX", 300);
return options;
}
async function finishAuthentication(
response: any,
expectedChallenge: string,
userId: string
) {
const credential = await db.credential.findUnique({
where: { credentialId: response.id },
});
if (!credential) {
throw new Error("Credential not found");
}
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: credential.credentialId,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports,
},
});
if (!verification.verified) {
throw new Error("Authentication verification failed");
}
// DŮLEŽITÉ: Aktualizace čítače pro prevenci replay útoků
await db.credential.update({
where: { credentialId: response.id },
data: {
counter: verification.authenticationInfo.newCounter,
},
});
return { verified: true, userId: credential.userId };
}// Klientská strana: Registrace
import { startRegistration as webAuthnRegister } from "@simplewebauthn/browser";
async function registerPasskey() {
// Získání možností z vašeho serveru
const optionsResponse = await fetch("/api/auth/webauthn/register", {
method: "POST",
});
const options = await optionsResponse.json();
try {
// Toto spustí UI passkey prohlížeče (biometrická výzva)
const credential = await webAuthnRegister(options);
// Odeslání credential na váš server k ověření
const verifyResponse = await fetch("/api/auth/webauthn/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credential),
});
const result = await verifyResponse.json();
if (result.verified) {
console.log("Passkey registered successfully!");
}
} catch (error) {
if ((error as Error).name === "NotAllowedError") {
console.log("User cancelled the passkey registration");
}
}
}Attestation vs Assertion#
Dva pojmy, se kterými se setkáte:
- Attestation (registrace): Proces vytváření nového credential. Autentikátor „dosvědčuje" svou identitu a schopnosti. Pro většinu aplikací nepotřebujete attestaci ověřovat — nastavte
attestationType: "none". - Assertion (autentizace): Proces použití existujícího credential k podepsání výzvy. Autentikátor „tvrdí", že uživatel je tím, za koho se vydává.
Implementace MFA#
I s passkeys narazíte na scénáře, kde je potřeba MFA přes TOTP — passkeys jako druhý faktor vedle hesel, nebo podpora uživatelů, jejichž zařízení passkeys nepodporují.
TOTP (Time-Based One-Time Passwords)#
TOTP je protokol za Google Authenticator, Authy a 1Password. Funguje tak, že:
- Server vygeneruje náhodný secret (base32-kódovaný)
- Uživatel naskenuje QR kód obsahující secret
- Server i autentikátorová aplikace vypočítají stejný 6místný kód ze secretu a aktuálního času
- Kódy se mění každých 30 sekund
import { createHmac, randomBytes } from "crypto";
// Generování TOTP secretu pro uživatele
function generateTOTPSecret(): string {
const buffer = randomBytes(20);
return base32Encode(buffer);
}
function base32Encode(buffer: Buffer): string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let result = "";
let bits = 0;
let value = 0;
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
result += alphabet[(value >>> (bits - 5)) & 0x1f];
bits -= 5;
}
}
if (bits > 0) {
result += alphabet[(value << (5 - bits)) & 0x1f];
}
return result;
}
// Generování TOTP URI pro QR kód
function generateTOTPUri(
secret: string,
userEmail: string,
issuer: string = "akousa.net"
): string {
const encodedIssuer = encodeURIComponent(issuer);
const encodedEmail = encodeURIComponent(userEmail);
return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}// Ověření TOTP kódu
function verifyTOTP(secret: string, code: string, window: number = 1): boolean {
const secretBuffer = base32Decode(secret);
const now = Math.floor(Date.now() / 1000);
// Kontrola aktuálního časového kroku a sousedních (tolerance odchylky hodin)
for (let i = -window; i <= window; i++) {
const timeStep = Math.floor(now / 30) + i;
const expectedCode = generateTOTPCode(secretBuffer, timeStep);
// Porovnání s konstantním časem pro prevenci timing útoků
if (timingSafeEqual(code, expectedCode)) {
return true;
}
}
return false;
}
function generateTOTPCode(secret: Buffer, timeStep: number): string {
// Převod časového kroku na 8bajtový big-endian buffer
const timeBuffer = Buffer.alloc(8);
timeBuffer.writeBigInt64BE(BigInt(timeStep));
// HMAC-SHA1
const hmac = createHmac("sha1", secret).update(timeBuffer).digest();
// Dynamické zkrácení
const offset = hmac[hmac.length - 1] & 0x0f;
const code =
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff);
return (code % 1_000_000).toString().padStart(6, "0");
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return createHmac("sha256", "key").update(bufA).digest()
.equals(createHmac("sha256", "key").update(bufB).digest());
}Záložní kódy#
Uživatelé ztrácejí telefony. Při nastavení MFA vždy generujte záložní kódy:
import { randomBytes, createHash } from "crypto";
function generateBackupCodes(count: number = 10): string[] {
return Array.from({ length: count }, () =>
randomBytes(4).toString("hex").toUpperCase() // 8znakové hex kódy
);
}
async function storeBackupCodes(userId: string, codes: string[]) {
// Hashování kódů před uložením — zacházejte s nimi jako s hesly
const hashedCodes = codes.map((code) =>
createHash("sha256").update(code).digest("hex")
);
await db.backupCode.createMany({
data: hashedCodes.map((hash) => ({
userId,
codeHash: hash,
used: false,
})),
});
// Vrácení prostých kódů JEDNOU pro uživatele k uložení
// Po tomto máme pouze hashe
return codes;
}
async function verifyBackupCode(userId: string, code: string): Promise<boolean> {
const codeHash = createHash("sha256")
.update(code.toUpperCase().replace(/\s/g, ""))
.digest("hex");
const backupCode = await db.backupCode.findFirst({
where: {
userId,
codeHash,
used: false,
},
});
if (!backupCode) return false;
// Označení jako použitý — každý záložní kód funguje přesně jednou
await db.backupCode.update({
where: { id: backupCode.id },
data: { used: true, usedAt: new Date() },
});
return true;
}Flow obnovy#
Obnova MFA je část, kterou většina tutoriálů přeskočí a většina reálných aplikací zkazí. Zde je, co implementuji:
- Primární: TOTP kód z autentikátorové aplikace
- Sekundární: Jeden z 10 záložních kódů
- Poslední možnost: Obnova přes email s 24hodinovou čekací dobou a notifikací na další ověřené kanály uživatele
Čekací doba je kritická. Pokud útočník kompromitoval email uživatele, nechcete mu umožnit okamžitě deaktivovat MFA. 24hodinový odklad dává legitimnímu uživateli čas si všimnout emailu a zasáhnout.
async function initiateAccountRecovery(email: string) {
const user = await db.user.findUnique({ where: { email } });
if (!user) {
// Neprozrazujte, zda účet existuje
return { message: "If that email exists, we've sent recovery instructions." };
}
const recoveryToken = randomBytes(32).toString("hex");
const tokenHash = createHash("sha256").update(recoveryToken).digest("hex");
await db.recoveryRequest.create({
data: {
userId: user.id,
tokenHash,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hodin
status: "pending",
},
});
// Odeslání emailu s odkazem na obnovu
await sendEmail(email, {
subject: "Account Recovery Request",
body: `
A request was made to disable MFA on your account.
If this was you, click the link below after 24 hours: ...
If this was NOT you, please change your password immediately.
`,
});
return { message: "If that email exists, we've sent recovery instructions." };
}Vzory autorizace#
Autentizace vám říká, kdo někdo je. Autorizace vám říká, co smí dělat. Pokud to pokazíte, skončíte ve zprávách.
RBAC vs ABAC#
RBAC (Role-Based Access Control): Uživatelé mají role, role mají oprávnění. Jednoduché, snadno pochopitelné, funguje pro většinu aplikací.
// RBAC — přímočará kontrola rolí
type Role = "user" | "editor" | "admin" | "super_admin";
const ROLE_PERMISSIONS: Record<Role, string[]> = {
user: ["read:own_profile", "update:own_profile", "read:posts"],
editor: ["read:own_profile", "update:own_profile", "read:posts", "create:posts", "update:posts"],
admin: [
"read:own_profile", "update:own_profile",
"read:posts", "create:posts", "update:posts", "delete:posts",
"read:users", "update:users",
],
super_admin: ["*"], // Opatrně s wildcards
};
function hasPermission(role: Role, permission: string): boolean {
const permissions = ROLE_PERMISSIONS[role];
return permissions.includes("*") || permissions.includes(permission);
}
// Použití v API routě
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasPermission(session.user.role as Role, "delete:posts")) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
await db.post.delete({ where: { id } });
return Response.json({ success: true });
}ABAC (Attribute-Based Access Control): Oprávnění závisí na atributech uživatele, zdroje a kontextu. Flexibilnější, ale složitější.
// ABAC — když RBAC nestačí
interface PolicyContext {
user: {
id: string;
role: string;
department: string;
clearanceLevel: number;
};
resource: {
type: string;
ownerId: string;
classification: string;
department: string;
};
action: string;
environment: {
ipAddress: string;
time: Date;
mfaVerified: boolean;
};
}
function evaluatePolicy(context: PolicyContext): boolean {
const { user, resource, action, environment } = context;
// Uživatelé mohou vždy číst vlastní zdroje
if (action === "read" && resource.ownerId === user.id) {
return true;
}
// Admini mohou číst jakýkoli zdroj ve svém oddělení
if (
action === "read" &&
user.role === "admin" &&
user.department === resource.department
) {
return true;
}
// Utajované zdroje vyžadují MFA a minimální bezpečnostní prověrku
if (resource.classification === "confidential") {
if (!environment.mfaVerified) return false;
if (user.clearanceLevel < 3) return false;
}
// Destruktivní akce blokované mimo pracovní dobu
if (action === "delete") {
const hour = environment.time.getHours();
if (hour < 9 || hour > 17) return false;
}
return false; // Výchozí zamítnutí
}Pravidlo „Kontrola na hranici"#
Toto je nejdůležitější princip autorizace: kontrolujte oprávnění na každé hranici důvěry, ne pouze na úrovni UI.
// ŠPATNĚ — kontrola pouze v komponentě
function DeleteButton({ post }: { post: Post }) {
const { data: session } = useSession();
// Toto skryje tlačítko, ale nebrání smazání
if (session?.user?.role !== "admin") return null;
return <button onClick={() => deletePost(post.id)}>Delete</button>;
}
// TAKÉ ŠPATNĚ — kontrola v server akci, ale ne v API routě
async function deletePostAction(postId: string) {
const session = await auth();
if (session?.user?.role !== "admin") throw new Error("Forbidden");
await db.post.delete({ where: { id: postId } });
}
// Útočník může stále zavolat POST /api/posts/123 přímo
// SPRÁVNĚ — kontrola na každé hranici
// 1. Skrytí tlačítka v UI (UX, ne bezpečnost)
// 2. Kontrola v server akci (obrana do hloubky)
// 3. Kontrola v API routě (skutečná bezpečnostní hranice)
// 4. Volitelně kontrola v middleware (pro ochranu na úrovni routy)Kontrola v UI je pro uživatelský zážitek. Kontrola na serveru je pro bezpečnost. Nikdy se nespoléhejte pouze na jednu z nich.
Kontroly oprávnění v Next.js Middleware#
Middleware běží před každým odpovídajícím požadavkem. Je to dobré místo pro hrubé řízení přístupu:
// "Smí tento uživatel vůbec přistoupit k této sekci?"
// Jemnozrnné kontroly ("Smí tento uživatel editovat TENTO příspěvek?") patří do route handleru,
// protože middleware nemá snadno přístup k tělu požadavku nebo parametrům routy.
export default auth((req) => {
const path = req.nextUrl.pathname;
const role = req.auth?.user?.role;
// Řízení přístupu na úrovni routy
const routeAccess: Record<string, Role[]> = {
"/admin": ["admin", "super_admin"],
"/editor": ["editor", "admin", "super_admin"],
"/dashboard": ["user", "editor", "admin", "super_admin"],
};
for (const [route, allowedRoles] of Object.entries(routeAccess)) {
if (path.startsWith(route)) {
if (!role || !allowedRoles.includes(role as Role)) {
return NextResponse.redirect(new URL("/unauthorized", req.nextUrl));
}
}
}
return NextResponse.next();
});Běžné zranitelnosti#
Toto jsou útoky, které nejčastěji vidím ve skutečných kódových bázích. Jejich pochopení je nezbytné.
Session Fixation#
Útok: Útočník vytvoří platnou session na vašem webu, pak přiměje oběť, aby tuto session ID použila (např. přes URL parametr nebo nastavením cookie přes subdoménu). Když se oběť přihlásí, session útočníka má nyní autentizovaného uživatele.
Oprava: Vždy regenerujte session ID po úspěšné autentizaci. Nikdy nenechte pre-autentizační session ID přejít do post-autentizační session.
async function login(credentials: { email: string; password: string }, request: Request) {
const user = await verifyCredentials(credentials);
if (!user) throw new Error("Invalid credentials");
// KRITICKÉ: Smazání staré session a vytvoření nové
const oldSessionId = getSessionIdFromCookie(request);
if (oldSessionId) {
await redis.del(`session:${oldSessionId}`);
}
// Vytvoření úplně nové session s novým ID
const newSessionId = await createSession(user.id, request);
return newSessionId;
}CSRF (Cross-Site Request Forgery)#
Útok: Uživatel je přihlášen na vašem webu. Navštíví škodlivou stránku, která provede požadavek na váš web. Protože cookies se odesílají automaticky, požadavek je autentizovaný.
Moderní oprava: SameSite cookies. Nastavení SameSite: Lax (nyní výchozí ve většině prohlížečů) brání odesílání cookies na cross-origin POST požadavky, což pokrývá většinu scénářů CSRF.
// SameSite=Lax pokrývá většinu scénářů CSRF:
// - Blokuje cookies na cross-origin POST, PUT, DELETE
// - Povoluje cookies na cross-origin GET (top-level navigace)
// Toto je v pořádku, protože GET požadavky by neměly mít vedlejší efekty
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax", // Toto je vaše ochrana CSRF
maxAge: 86400,
path: "/",
});Pro API, která přijímají JSON, dostáváte další ochranu zdarma: hlavičku Content-Type: application/json nelze nastavit HTML formuláři a CORS brání JavaScriptu na jiných originech v provádění požadavků s vlastními hlavičkami.
Pokud potřebujete silnější záruky (např. přijímáte odesílání formulářů), použijte vzor double-submit cookie nebo synchronizační token. Auth.js to řeší za vás.
Open Redirects v OAuth#
Útok: Útočník vytvoří OAuth callback URL, které po autentizaci přesměruje na jeho web: https://yourapp.com/callback?redirect_to=https://evil.com/steal-token
Pokud váš callback handler slepě přesměruje na parametr redirect_to, uživatel skončí na webu útočníka, potenciálně s tokeny v URL.
// ZRANITELNÉ
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// ... autentizace uživatele ...
return Response.redirect(redirectTo); // Mohlo by být https://evil.com!
}
// BEZPEČNÉ
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// Validace přesměrovacího URL
const safeRedirect = sanitizeRedirectUrl(redirectTo, request.url);
// ... autentizace uživatele ...
return Response.redirect(safeRedirect);
}
function sanitizeRedirectUrl(redirect: string, baseUrl: string): string {
try {
const url = new URL(redirect, baseUrl);
const base = new URL(baseUrl);
// Povolení přesměrování pouze na stejný origin
if (url.origin !== base.origin) {
return "/";
}
// Povolení pouze přesměrování na cestu (žádné javascript: nebo data: URI)
if (!url.pathname.startsWith("/")) {
return "/";
}
return url.pathname + url.search;
} catch {
return "/";
}
}Únik tokenů přes Referrer#
Pokud vkládáte tokeny do URL (nedělejte to), uniknou přes hlavičku Referer, když uživatelé kliknou na odkazy. Toto způsobilo skutečné úniky, včetně na GitHubu.
Pravidla:
- Nikdy nevkládejte tokeny do URL query parametrů pro autentizaci
- Nastavte
Referrer-Policy: strict-origin-when-cross-origin(nebo přísnější) - Pokud musíte vložit tokeny do URL (např. verifikační odkazy v emailu), udělejte je jednorázové a krátkodobé
// Ve vašem Next.js middleware nebo layoutu
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");JWT Key Injection#
Méně známý útok: některé JWT knihovny podporují hlavičku jwk nebo jku, která říká ověřovateli, kde najít veřejný klíč. Útočník může:
- Vygenerovat vlastní pár klíčů
- Vytvořit JWT se svým payloadem a podepsat ho svým privátním klíčem
- Nastavit hlavičku
jwktak, aby ukazovala na jeho veřejný klíč
Pokud vaše knihovna slepě načte a použije klíč z hlavičky jwk, podpis se ověří. Oprava: nikdy nedovolte tokenu specifikovat svůj vlastní ověřovací klíč. Vždy používejte klíče z vaší vlastní konfigurace.
Můj Auth stack v roce 2026#
Po letech budování autentizačních systémů, zde je, co skutečně dnes používám.
Pro většinu projektů: Auth.js + PostgreSQL + Passkeys#
Toto je můj výchozí stack pro nové projekty:
- Auth.js (v5) pro těžkou práci: OAuth poskytovatelé, správa sessions, CSRF, databázový adaptér
- PostgreSQL s Prisma adaptérem pro úložiště sessions a účtů
- Passkeys přes SimpleWebAuthn jako primární přihlašovací metoda pro nové uživatele
- Email/heslo jako fallback pro uživatele, kteří nemohou použít passkeys
- TOTP MFA jako druhý faktor pro přihlášení založená na heslech
Strategie sessions je podpořena databází (ne JWT), což mi dává okamžité zneplatnění a jednoduchou správu sessions.
// Toto je můj typický auth.ts pro nový projekt
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Passkey from "next-auth/providers/passkey";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "database" },
providers: [
Google,
GitHub,
Passkey({
// Auth.js v5 má vestavěnou podporu passkeys
// Pod kapotou používá SimpleWebAuthn
}),
],
experimental: {
enableWebAuthn: true,
},
});Kdy místo toho použít Clerk nebo Auth0#
Po spravovaném poskytovateli auth sáhnu, když:
- Projekt potřebuje enterprise SSO (SAML, SCIM). Správná implementace SAML je několikaměsíční projekt. Clerk to má připravené.
- Tým nemá bezpečnostní expertízu. Pokud nikdo v týmu nedokáže vysvětlit PKCE, neměli by budovat auth od nuly.
- Rychlost uvedení na trh je důležitější než náklady. Auth.js je zdarma, ale správné nastavení trvá dny. Clerk zabere odpoledne.
- Potřebujete záruky shody (SOC 2, HIPAA). Spravovaní poskytovatelé se starají o certifikaci shody.
Kompromisy spravovaných poskytovatelů:
- Náklady: Clerk účtuje za měsíčně aktivního uživatele. V měřítku se to nasčítá.
- Vendor lock-in: Migrace pryč od Clerk nebo Auth0 je bolestivá. Vaše tabulka uživatelů je na jejich serverech.
- Limity přizpůsobení: Pokud je váš auth flow neobvyklý, budete bojovat s názory poskytovatele.
- Latence: Každá kontrola auth jde na API třetí strany. S databázovými sessions je to lokální dotaz.
Čemu se vyhýbám#
- Vlastní kryptografie. Používám
josepro JWT,@simplewebauthn/serverpro passkeys,bcryptneboargon2pro hesla. Nikdy ručně vyrobené. - Ukládání hesel v SHA256. Použijte bcrypt (cost factor 12+) nebo argon2id. SHA256 je příliš rychlý — útočník může vyzkoušet miliardy hashů za sekundu s GPU.
- Dlouhodobé access tokeny. Maximum 15 minut. Použijte rotaci refresh tokenů pro delší sessions.
- Symetrické secrets pro ověřování napříč službami. Pokud více služeb potřebuje ověřovat tokeny, použijte RS256 s párem veřejného/privátního klíče.
- Vlastní session ID s nedostatečnou entropií. Použijte
crypto.randomBytes(32)minimum. UUID v4 je přijatelné, ale má méně entropie než surové náhodné bajty.
Hashování hesel: Správný způsob#
Jelikož jsme to zmínili — zde je, jak správně hashovat hesla v roce 2026:
import { hash, verify } from "@node-rs/argon2";
// Argon2id je doporučený algoritmus
// Toto jsou rozumné výchozí hodnoty pro webovou aplikaci
async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterace
parallelism: 4, // 4 vlákna
});
}
async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
try {
return await verify(hashedPassword, password);
} catch {
return false;
}
}Proč argon2id místo bcrypt? Argon2id je paměťově náročný, což znamená, že útok na něj vyžaduje nejen výpočetní výkon, ale také velké množství RAM. To dělá útoky GPU a ASIC výrazně dražšími. Bcrypt je stále v pořádku — není prolomený — ale argon2id je lepší volba pro nové projekty.
Bezpečnostní checklist#
Před nasazením jakéhokoli autentizačního systému ověřte:
- Hesla jsou hashována pomocí argon2id nebo bcrypt (cost 12+)
- Sessions jsou regenerovány po přihlášení (prevence session fixation)
- Cookies jsou
HttpOnly,Secure,SameSite=LaxneboStrict - JWT specifikují algoritmy explicitně (nikdy nevěřte hlavičce
alg) - Access tokeny expirují za 15 minut nebo méně
- Rotace refresh tokenů je implementována s detekcí opětovného použití
- OAuth state parametr je ověřen (ochrana CSRF)
- Přesměrovací URL jsou validovány proti allowlistu
- Rate limiting je aplikován na login, registraci a reset hesla endpointy
- Neúspěšné pokusy o přihlášení jsou logovány s IP, ale ne s hesly
- Uzamčení účtu po N neúspěšných pokusech (s progresivním zpožděním, ne trvalý zámek)
- Tokeny pro reset hesla jsou jednorázové a expirují za 1 hodinu
- MFA záložní kódy jsou hashovány jako hesla
- CORS je nakonfigurován pro povolení pouze známých originů
- Hlavička
Referrer-Policyje nastavena - Žádná citlivá data v JWT payloadech (jsou čitelná kýmkoli)
- WebAuthn čítač je ověřen a aktualizován (prevence klonování credential)
Tento seznam není vyčerpávající, ale pokrývá zranitelnosti, které jsem nejčastěji viděl v produkčních systémech.
Závěr#
Autentizace je jednou z těch oblastí, kde se krajina neustále vyvíjí, ale základy zůstávají stejné: ověřte identitu, vydejte minimálně nutné přihlašovací údaje, kontrolujte oprávnění na každé hranici a předpokládejte průnik.
Největší posun v roce 2026 je, že passkeys se dostávají do mainstreamu. Podpora prohlížečů je univerzální, podpora platforem (iCloud Keychain, Google Password Manager) činí UX bezproblémovým a bezpečnostní vlastnosti jsou skutečně nadřazené čemukoli, co jsme měli dříve. Pokud budujete novou aplikaci, udělejte passkeys svou primární přihlašovací metodou a hesla považujte za fallback.
Druhý největší posun je, že vytváření vlastního auth se stalo těžší ospravedlnit. Auth.js v5, Clerk a podobná řešení zvládají obtížné části správně. Jediný důvod jít na vlastní řešení je, když vaše požadavky skutečně neodpovídají žádnému existujícímu řešení — a to je vzácnější, než si většina vývojářů myslí.
Ať si vyberete cokoliv, testujte svůj auth tak, jak by to dělal útočník. Zkuste přehrávat tokeny, padělat podpisy, přistupovat k routám, ke kterým byste neměli, a manipulovat s přesměrovacími URL. Chyby, které najdete před spuštěním, jsou ty, které se nedostanou do zpráv.