Pełen krajobraz autentykacji: kiedy używać sesji vs JWT, przepływy OAuth 2.0 / OIDC, rotacja refresh tokenów, passkeys (WebAuthn) i wzorce auth w Next.js, których naprawdę używam.
Autentykacja to jedyny obszar web developmentu, gdzie "działa" nigdy nie jest wystarczające. Bug w twoim datepickerze jest irytujący. Bug w twoim systemie auth to wyciek danych.
Implementowałem autentykację od zera, migrowałem między dostawcami, debugowałem incydenty kradzieży tokenów i radziłem sobie ze skutkami decyzji "naprawimy bezpieczeństwo później". Ten post to kompleksowy przewodnik, którego żałowałem, że nie miałem, kiedy zaczynałem. Nie sama teoria — realne kompromisy, prawdziwe podatności i wzorce, które wytrzymują presję produkcji.
Omówimy cały krajobraz: sesje, JWT, OAuth 2.0, passkeys, MFA i autoryzację. Pod koniec zrozumiesz nie tylko jak każdy mechanizm działa, ale kiedy go użyć i dlaczego istnieją alternatywy.
To pierwsza decyzja, z jaką się zmierzysz, a internet jest pełen złych rad na ten temat. Pozwól, że przedstawię to, co naprawdę ma znaczenie.
Sesje to oryginalne podejście. Serwer tworzy rekord sesji, przechowuje go gdzieś (baza danych, Redis, pamięć) i daje klientowi nieprzejrzysty identyfikator sesji w cookie.
// Simplified session creation
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 hours
ipAddress: request.headers.get("x-forwarded-for") ?? "unknown",
userAgent: request.headers.get("user-agent") ?? "unknown",
};
// Store in your database or Redis
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;
}Zalety są realne:
Wady są również realne:
JWT odwracają model. Zamiast przechowywać stan sesji na serwerze, kodujesz go w podpisany token, który trzyma 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://example.com")
.setAudience("https://example.com")
.sign(secret);
}
async function verifyAccessToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret, {
issuer: "https://example.com",
audience: "https://example.com",
});
return payload;
} catch {
return null;
}
}Zalety:
Wady — i to te, które ludzie pomijają:
Moja reguła kciuka:
Używaj sesji, gdy: Masz monolityczną aplikację, potrzebujesz natychmiastowego unieważnienia, budujesz produkt konsumencki, gdzie bezpieczeństwo konta jest krytyczne, lub twoje wymagania auth mogą się często zmieniać.
Używaj JWT, gdy: Masz architekturę mikroserwisową, gdzie serwisy muszą niezależnie weryfikować tożsamość, budujesz komunikację API-do-API, lub implementujesz system autentykacji dla stron trzecich.
W praktyce: Większość aplikacji powinna używać sesji. Argument "JWT są bardziej skalowalne" ma zastosowanie tylko wtedy, gdy faktycznie masz problem skalowania, którego magazyn sesji nie rozwiąże — a Redis obsługuje miliony wyszukiwań sesji na sekundę. Widziałem zbyt wiele projektów, które wybrały JWT, bo brzmiały nowocześniej, a potem zbudowały blocklistę i system refresh tokenów, który jest bardziej złożony niż sesje by były.
Nawet jeśli wybierzesz auth oparte na sesjach, napotkasz JWT przez OAuth, OIDC i integracje z zewnętrznymi serwisami. Rozumienie mechanizmów wewnętrznych jest bezdyskusyjne.
JWT ma trzy części oddzielone kropkami: header.payload.signature
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...
Header — deklaruje algorytm i typ tokena:
{
"alg": "RS256",
"typ": "JWT"
}Payload — zawiera roszczenia. Standardowe roszczenia mają krótkie nazwy:
{
"sub": "user_123", // Subject (who is this about)
"iss": "https://auth.example.com", // Issuer (who created this)
"aud": "https://api.example.com", // Audience (who should accept this)
"iat": 1709312000, // Issued At (Unix timestamp)
"exp": 1709312900, // Expiration (Unix timestamp)
"role": "admin" // Custom claim
}Signature — dowodzi, że token nie został zmodyfikowany. Tworzony przez podpisanie zakodowanego headera i payloadu kluczem tajnym.
HS256 (HMAC-SHA256) — symetryczny. Ten sam sekret podpisuje i weryfikuje. Proste, ale każdy serwis, który musi weryfikować tokeny, musi mieć sekret. Jeśli którykolwiek z nich zostanie skompromitowany, atakujący może fałszować tokeny.
RS256 (RSA-SHA256) — asymetryczny. Klucz prywatny podpisuje, klucz publiczny weryfikuje. Tylko serwer auth potrzebuje klucza prywatnego. Każdy serwis może weryfikować kluczem publicznym. Jeśli serwis weryfikujący zostanie skompromitowany, atakujący może czytać tokeny, ale nie fałszować.
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
// RS256 — use this when multiple services verify tokens
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"], // CRITICAL: always restrict algorithms
});
return payload;
}Zasada: Używaj RS256 zawsze, gdy tokeny przekraczają granice serwisów. Używaj HS256 tylko wtedy, gdy ten sam serwis zarówno podpisuje, jak i weryfikuje.
alg: noneTo najsłynniejsza podatność JWT i jest żenująco prosta. Niektóre biblioteki JWT kiedyś:
alg z headeraalg: "none", pomijały weryfikację podpisu całkowicieAtakujący mógł wziąć ważny JWT, zmienić payload (np. ustawić "role": "admin"), ustawić alg na "none", usunąć podpis i go wysłać. Serwer by go zaakceptował.
// VULNERABLE — never do this
function verifyJwt(token: string) {
const [headerB64, payloadB64, signature] = token.split(".");
const header = JSON.parse(atob(headerB64));
if (header.alg === "none") {
// "No signature needed" — CATASTROPHIC
return JSON.parse(atob(payloadB64));
}
// ... verify signature
}Poprawka jest prosta: zawsze jawnie podawaj oczekiwany algorytm. Nigdy nie pozwól tokenowi powiedzieć ci, jak go weryfikować.
// SAFE — algorithm is hardcoded, not read from the token
const { payload } = await jwtVerify(token, key, {
algorithms: ["RS256"], // Only accept RS256 — ignore the header
});Nowoczesne biblioteki jak jose domyślnie obsługują to poprawnie, ale mimo to powinieneś jawnie przekazać opcję algorithms jako obrona w głąb.
Powiązany z powyższym: jeśli serwer jest skonfigurowany do akceptowania RS256, atakujący może:
alg: "HS256"Jeśli serwer czyta header alg i przełącza się na weryfikację HS256, klucz publiczny (który wszyscy znają) staje się współdzielonym sekretem. Podpis jest ważny. Atakujący sfałszował token.
Ponownie, poprawka jest ta sama: nigdy nie ufaj algorytmowi z headera tokena. Zawsze go hardkoduj.
Jeśli używasz JWT, potrzebujesz strategii refresh tokenów. Wysyłanie długotrwałego access tokena to proszenie się o kłopoty — jeśli zostanie ukradziony, atakujący ma dostęp przez cały czas życia tokena.
Wzorzec:
import { randomBytes } from "crypto";
interface RefreshTokenRecord {
tokenHash: string;
userId: string;
familyId: string; // Groups related tokens together
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);
// Store refresh token record
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 };
}Za każdym razem, gdy klient używa refresh tokena do pobrania nowego access tokena, wystawiasz nowy refresh token i unieważniasz stary:
async function rotateTokens(incomingRefreshToken: string) {
const tokenHash = await hashToken(incomingRefreshToken);
const record = await db.refreshToken.findUnique({
where: { tokenHash },
});
if (!record) {
// Token doesn't exist — possible theft
return null;
}
if (record.expiresAt < new Date()) {
// Token expired
await db.refreshToken.delete({ where: { tokenHash } });
return null;
}
if (record.used) {
// THIS TOKEN WAS ALREADY USED.
// Someone is replaying it — either the legitimate user
// or an attacker. Either way, kill the entire family.
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;
}
// Mark current token as used (don't delete — we need it for reuse detection)
await db.refreshToken.update({
where: { tokenHash },
data: { used: true },
});
// Issue new pair with the same family ID
const newRefreshToken = randomBytes(64).toString("hex");
const newRefreshTokenHash = await hashToken(newRefreshToken);
await db.refreshToken.create({
data: {
tokenHash: newRefreshTokenHash,
userId: record.userId,
familyId: record.familyId, // Same family
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 };
}Rozważ taki scenariusz:
Bez wykrywania ponownego użycia użytkownik po prostu dostaje błąd. Atakujący kontynuuje z tokenem B. Użytkownik loguje się ponownie, nigdy nie wiedząc, że jego konto zostało skompromitowane.
Z wykrywaniem ponownego użycia i unieważnieniem rodziny: gdy użytkownik próbuje użyć już użytego tokena A, system wykrywa ponowne użycie, unieważnia każdy token w rodzinie (w tym B) i zmusza zarówno użytkownika, jak i atakującego do ponownej autentykacji. Użytkownik dostaje komunikat "zaloguj się ponownie" i może zdać sobie sprawę, że coś jest nie tak.
To podejście stosowane przez Auth0, Okta i Auth.js. Nie jest idealne — jeśli atakujący użyje tokena przed prawowitym użytkownikiem, to prawowity użytkownik staje się tym, który wyzwala alert o ponownym użyciu. Ale to najlepsze, co możemy zrobić z bearer tokenami.
OAuth 2.0 i OpenID Connect to protokoły stojące za "Zaloguj się przez Google/GitHub/Apple". Rozumienie ich jest niezbędne nawet jeśli używasz biblioteki, bo gdy coś się zepsuje — a zepsuje się — musisz wiedzieć, co dzieje się na poziomie protokołu.
OAuth 2.0 to protokół autoryzacji. Odpowiada: "Czy ta aplikacja może uzyskać dostęp do danych tego użytkownika?" Wynikiem jest access token dający określone uprawnienia (scope'y).
OpenID Connect (OIDC) to warstwa autentykacji zbudowana na OAuth 2.0. Odpowiada: "Kim jest ten użytkownik?" Wynikiem jest ID token (JWT), który zawiera informacje o tożsamości użytkownika.
Kiedy "Logujesz się przez Google", używasz OIDC. Google mówi twojej aplikacji, kim jest użytkownik (autentykacja). Możesz też poprosić o scope'y OAuth, żeby uzyskać dostęp do kalendarza lub dysku (autoryzacja).
To przepływ, którego powinieneś używać do aplikacji webowych. PKCE (Proof Key for Code Exchange) został pierwotnie zaprojektowany dla aplikacji mobilnych, ale teraz jest zalecany dla wszystkich klientów, w tym aplikacji serwerowych.
import { randomBytes, createHash } from "crypto";
// Step 1: Generate PKCE values and redirect the user
function initiateOAuthFlow() {
// Code verifier: random 43-128 character string
const codeVerifier = randomBytes(32).toString("base64url").slice(0, 43);
// Code challenge: SHA256 hash of the verifier, base64url-encoded
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
// State: random value for CSRF protection
const state = randomBytes(16).toString("hex");
// Store both in the session (server-side!) before redirecting
// NEVER put the code_verifier in a cookie or URL parameter
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();
}// Step 2: Handle the callback
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");
// Check for errors from the provider
if (error) {
throw new Error(`OAuth error: ${error}`);
}
// Verify state matches (CSRF protection)
if (state !== session.oauthState) {
throw new Error("State mismatch — possible CSRF attack");
}
// Exchange the authorization code for tokens
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: proves we started this flow
}),
});
const tokens = await tokenResponse.json();
// tokens.access_token — for API calls to Google
// tokens.id_token — JWT with user identity (OIDC)
// tokens.refresh_token — for getting new access tokens
// Step 3: Verify the ID token and extract user info
const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
return {
googleId: idTokenPayload.sub,
email: idTokenPayload.email,
name: idTokenPayload.name,
picture: idTokenPayload.picture,
};
}Każdy dostawca OAuth/OIDC udostępnia:
Parametr state zapobiega atakom CSRF na callback OAuth. Bez niego:
https://twojaapka.com/callback?code=KOD_ATAKUJACEGOZ state: twoja aplikacja generuje losową wartość, zapisuje ją w sesji i dołącza do URL autoryzacji. Gdy callback przychodzi, weryfikujesz, czy state się zgadza. Atakujący nie może tego sfałszować, bo nie ma dostępu do sesji ofiary.
Auth.js to narzędzie, po które sięgam w pierwszej kolejności w większości projektów Next.js. Obsługuje taniec OAuth, zarządzanie sesjami, trwałość bazodanową i ochronę CSRF. Oto konfiguracja gotowa na produkcję.
// 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),
// Use database sessions (not JWT) for better security
session: {
strategy: "database",
maxAge: 30 * 24 * 60 * 60, // 30 days
updateAge: 24 * 60 * 60, // Extend session every 24 hours
},
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
// Request specific scopes
authorization: {
params: {
scope: "openid email profile",
prompt: "consent",
access_type: "offline", // Get refresh token
},
},
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
// Email/password login (use carefully)
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: {
// Control who can sign in
async signIn({ user, account }) {
// Block sign-in for banned users
if (user.id) {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { banned: true },
});
if (dbUser?.banned) return false;
}
return true;
},
// Add custom fields to the session
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
// Fetch role from database
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",
},
});// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;// 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");
// Redirect logged-in users away from auth pages
if (isLoggedIn && isAuthPage) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
// Redirect unauthenticated users to login
if (!isLoggedIn && isProtectedRoute) {
const callbackUrl = encodeURIComponent(req.nextUrl.pathname);
return NextResponse.redirect(new URL(`/login?callbackUrl=${callbackUrl}`, req.nextUrl));
}
// Check 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"],
};// 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>
);
}"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 to najważniejsze ulepszenie autentykacji od lat. Są odporne na phishing, odporne na replay i eliminują całą kategorię podatności związanych z hasłami. Jeśli zaczynasz nowy projekt w 2026 roku, powinieneś wspierać passkeys.
Passkeys używają kryptografii klucza publicznego, wspieranej przez biometrię lub PIN urządzenia:
Żaden współdzielony sekret nigdy nie przekracza sieci. Nie ma nic do wyłudzenia, nic do wycieku, nic do credential stuffingu.
Gdy passkey jest tworzony, jest powiązany z originem (np. https://example.com). Przeglądarka użyje passkeya tylko na dokładnie tym originie, na którym został stworzony. Jeśli atakujący stworzy podrobioną stronę na https://exarnple.com, passkey po prostu nie zostanie zaoferowany. To jest wymuszone przez przeglądarkę, nie przez czujność użytkownika.
To jest fundamentalnie inne od haseł, gdzie użytkownicy rutynowo wpisują swoje dane logowania na stronach phishingowych, bo strona wygląda prawidłowo.
SimpleWebAuthn to biblioteka, którą polecam. Poprawnie obsługuje protokół WebAuthn i ma dobre typy TypeScript.
// Server-side: Registration
import { generateRegistrationOptions, verifyRegistrationResponse } from "@simplewebauthn/server";
import type { GenerateRegistrationOptionsOpts, VerifiedRegistrationResponse } from "@simplewebauthn/server";
const rpName = "example.com";
const rpID = "example.com";
const origin = "https://example.com";
async function startRegistration(userId: string, userEmail: string) {
// Get user's existing passkeys to exclude them
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", // We don't need attestation for most apps
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
};
const registrationOptions = await generateRegistrationOptions(options);
// Store the challenge temporarily — we need it for verification
await redis.set(
`webauthn:challenge:${userId}`,
registrationOptions.challenge,
"EX",
300, // 5 minute expiry
);
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;
// Store the credential in the database
await db.credential.create({
data: {
userId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: credential.transports ?? [],
},
});
// Clean up
await redis.del(`webauthn:challenge:${userId}`);
return { verified: true };
}// Server-side: Authentication
import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
async function startAuthentication(userId?: string) {
let allowCredentials;
// If we know the user (e.g., they entered their email), limit to their 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",
});
// Store challenge for verification
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");
}
// IMPORTANT: Update the counter to prevent replay attacks
await db.credential.update({
where: { credentialId: response.id },
data: {
counter: verification.authenticationInfo.newCounter,
},
});
return { verified: true, userId: credential.userId };
}// Client-side: Registration
import { startRegistration as webAuthnRegister } from "@simplewebauthn/browser";
async function registerPasskey() {
// Get options from your server
const optionsResponse = await fetch("/api/auth/webauthn/register", {
method: "POST",
});
const options = await optionsResponse.json();
try {
// This triggers the browser's passkey UI (biometric prompt)
const credential = await webAuthnRegister(options);
// Send the credential to your server for verification
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");
}
}
}Dwa terminy, na które natrafisz:
attestationType: "none".Nawet z passkeys, napotkasz scenariusze, gdzie MFA przez TOTP jest potrzebne — passkeys jako drugi czynnik obok haseł, lub wsparcie użytkowników, których urządzenia nie obsługują passkeys.
TOTP to protokół stojący za Google Authenticator, Authy i 1Password. Działa tak:
import { createHmac, randomBytes } from "crypto";
// Generate a TOTP secret for a user
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;
}
// Generate the TOTP URI for QR code
function generateTOTPUri(secret: string, userEmail: string, issuer: string = "example.com"): string {
const encodedIssuer = encodeURIComponent(issuer);
const encodedEmail = encodeURIComponent(userEmail);
return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}// Verify a TOTP code
function verifyTOTP(secret: string, code: string, window: number = 1): boolean {
const secretBuffer = base32Decode(secret);
const now = Math.floor(Date.now() / 1000);
// Check current time step and adjacent ones (clock drift tolerance)
for (let i = -window; i <= window; i++) {
const timeStep = Math.floor(now / 30) + i;
const expectedCode = generateTOTPCode(secretBuffer, timeStep);
// Constant-time comparison to prevent timing attacks
if (timingSafeEqual(code, expectedCode)) {
return true;
}
}
return false;
}
function generateTOTPCode(secret: Buffer, timeStep: number): string {
// Convert time step to 8-byte big-endian buffer
const timeBuffer = Buffer.alloc(8);
timeBuffer.writeBigInt64BE(BigInt(timeStep));
// HMAC-SHA1
const hmac = createHmac("sha1", secret).update(timeBuffer).digest();
// Dynamic truncation
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());
}Użytkownicy gubią telefony. Zawsze generuj kody zapasowe podczas konfiguracji MFA:
import { randomBytes, createHash } from "crypto";
function generateBackupCodes(count: number = 10): string[] {
return Array.from(
{ length: count },
() => randomBytes(4).toString("hex").toUpperCase(), // 8-character hex codes
);
}
async function storeBackupCodes(userId: string, codes: string[]) {
// Hash the codes before storing — treat them like passwords
const hashedCodes = codes.map((code) => createHash("sha256").update(code).digest("hex"));
await db.backupCode.createMany({
data: hashedCodes.map((hash) => ({
userId,
codeHash: hash,
used: false,
})),
});
// Return the plain codes ONCE for the user to save
// After this, we only have the hashes
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;
// Mark as used — each backup code works exactly once
await db.backupCode.update({
where: { id: backupCode.id },
data: { used: true, usedAt: new Date() },
});
return true;
}Odzyskiwanie MFA to część, którą większość tutoriali pomija, a większość prawdziwych aplikacji partaczy. Oto co implementuję:
Okres oczekiwania jest krytyczny. Jeśli atakujący skompromitował email użytkownika, nie chcesz pozwolić mu natychmiast wyłączyć MFA. 24-godzinne opóźnienie daje prawowitemu użytkownikowi czas na zauważenie maila i interwencję.
async function initiateAccountRecovery(email: string) {
const user = await db.user.findUnique({ where: { email } });
if (!user) {
// Don't reveal whether the account exists
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 hours
status: "pending",
},
});
// Send email with recovery link
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." };
}Autentykacja mówi ci, kim ktoś jest. Autoryzacja mówi ci, co wolno mu robić. Pomylenie tego to sposób, żeby trafić do wiadomości.
RBAC (Role-Based Access Control): Użytkownicy mają role, role mają uprawnienia. Proste, łatwe do zrozumienia, działa dla większości aplikacji.
// RBAC — straightforward role checks
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: ["*"], // Careful with wildcards
};
function hasPermission(role: Role, permission: string): boolean {
const permissions = ROLE_PERMISSIONS[role];
return permissions.includes("*") || permissions.includes(permission);
}
// Usage in an API route
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): Uprawnienia zależą od atrybutów użytkownika, zasobu i kontekstu. Bardziej elastyczne, ale bardziej złożone.
// ABAC — when RBAC isn't enough
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;
// Users can always read their own resources
if (action === "read" && resource.ownerId === user.id) {
return true;
}
// Admins can read any resource in their department
if (action === "read" && user.role === "admin" && user.department === resource.department) {
return true;
}
// Classified resources require MFA and minimum clearance
if (resource.classification === "confidential") {
if (!environment.mfaVerified) return false;
if (user.clearanceLevel < 3) return false;
}
// Destructive actions blocked outside business hours
if (action === "delete") {
const hour = environment.time.getHours();
if (hour < 9 || hour > 17) return false;
}
return false; // Default deny
}To najważniejsza zasada autoryzacji: sprawdzaj uprawnienia na każdej granicy zaufania, nie tylko na poziomie UI.
// BAD — only checking in the component
function DeleteButton({ post }: { post: Post }) {
const { data: session } = useSession();
// This hides the button, but doesn't prevent deletion
if (session?.user?.role !== "admin") return null;
return <button onClick={() => deletePost(post.id)}>Delete</button>;
}
// ALSO BAD — checking in a server action but not the API route
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 } });
}
// An attacker can still call POST /api/posts/123 directly
// GOOD — check at every boundary
// 1. Hide the button in the UI (UX, not security)
// 2. Check in the server action (defense in depth)
// 3. Check in the API route (the actual security boundary)
// 4. Optionally, check in middleware (for route-level protection)Sprawdzenie w UI jest dla doświadczenia użytkownika. Sprawdzenie na serwerze jest dla bezpieczeństwa. Nigdy nie polegaj tylko na jednym z nich.
Middleware działa przed każdym dopasowanym żądaniem. To dobre miejsce na gruboziarnistą kontrolę dostępu:
// "Is this user allowed to access this section at all?"
// Fine-grained checks ("Can this user edit THIS post?") belong in the route handler
// because middleware doesn't have access to the request body or route params easily.
export default auth((req) => {
const path = req.nextUrl.pathname;
const role = req.auth?.user?.role;
// Route-level access control
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();
});To ataki, które najczęściej widzę w prawdziwych bazach kodu. Rozumienie ich jest niezbędne.
Atak: Atakujący tworzy ważną sesję na twojej stronie, a następnie oszukuje ofiarę, żeby użyła tego identyfikatora sesji (np. przez parametr URL lub ustawiając cookie przez subdomenę). Gdy ofiara się loguje, sesja atakującego ma teraz uwierzytelnionego użytkownika.
Poprawka: Zawsze regeneruj identyfikator sesji po udanej autentykacji. Nigdy nie pozwól, żeby identyfikator sesji sprzed autentykacji przeszedł do sesji po autentykacji.
async function login(credentials: { email: string; password: string }, request: Request) {
const user = await verifyCredentials(credentials);
if (!user) throw new Error("Invalid credentials");
// CRITICAL: Delete the old session and create a new one
const oldSessionId = getSessionIdFromCookie(request);
if (oldSessionId) {
await redis.del(`session:${oldSessionId}`);
}
// Create a completely new session with a new ID
const newSessionId = await createSession(user.id, request);
return newSessionId;
}Atak: Użytkownik jest zalogowany na twojej stronie. Odwiedza złośliwą stronę, która wysyła żądanie do twojej strony. Ponieważ cookies są wysyłane automatycznie, żądanie jest uwierzytelnione.
Nowoczesna poprawka: Cookies SameSite. Ustawienie SameSite: Lax (domyślne w większości przeglądarek) zapobiega wysyłaniu cookies przy cross-origin POST, co pokrywa większość scenariuszy CSRF.
// SameSite=Lax covers most CSRF scenarios:
// - Blocks cookies on cross-origin POST, PUT, DELETE
// - Allows cookies on cross-origin GET (top-level navigation)
// This is fine because GET requests shouldn't have side effects
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax", // This is your CSRF protection
maxAge: 86400,
path: "/",
});Dla API, które akceptują JSON, dostajesz dodatkową ochronę za darmo: nagłówka Content-Type: application/json nie można ustawić z formularzy HTML, a CORS blokuje JavaScript z innych originów przed wysyłaniem żądań z niestandardowymi nagłówkami.
Jeśli potrzebujesz silniejszych gwarancji (np. akceptujesz formularze), użyj wzorca double-submit cookie lub synchronizer tokena. Auth.js obsługuje to za ciebie.
Atak: Atakujący tworzy URL callbacku OAuth, który przekierowuje na jego stronę po autentykacji: https://twojaapka.com/callback?redirect_to=https://evil.com/steal-token
Jeśli twój handler callbacku ślepo przekierowuje na parametr redirect_to, użytkownik trafia na stronę atakującego, potencjalnie z tokenami w URL.
// VULNERABLE
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// ... authenticate the user ...
return Response.redirect(redirectTo); // Could be https://evil.com!
}
// SAFE
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// Validate the redirect URL
const safeRedirect = sanitizeRedirectUrl(redirectTo, request.url);
// ... authenticate the user ...
return Response.redirect(safeRedirect);
}
function sanitizeRedirectUrl(redirect: string, baseUrl: string): string {
try {
const url = new URL(redirect, baseUrl);
const base = new URL(baseUrl);
// Only allow redirects to the same origin
if (url.origin !== base.origin) {
return "/";
}
// Only allow path redirects (no javascript: or data: URIs)
if (!url.pathname.startsWith("/")) {
return "/";
}
return url.pathname + url.search;
} catch {
return "/";
}
}Jeśli umieszczasz tokeny w URL-ach (nie rób tego), wyciekną przez nagłówek Referer, gdy użytkownicy klikają linki. To spowodowało prawdziwe wycieki, w tym w GitHub.
Zasady:
Referrer-Policy: strict-origin-when-cross-origin (lub bardziej restrykcyjne)// In your Next.js middleware or layout
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");Mniej znany atak: niektóre biblioteki JWT obsługują nagłówek jwk lub jku, który mówi weryfikatorowi, gdzie znaleźć klucz publiczny. Atakujący może:
jwk wskazujący na swój klucz publicznyJeśli twoja biblioteka ślepo pobiera i używa klucza z nagłówka jwk, podpis się weryfikuje. Poprawka: nigdy nie pozwól tokenowi określać własnego klucza weryfikacji. Zawsze używaj kluczy z własnej konfiguracji.
Po latach budowania systemów autentykacji, oto czego faktycznie używam dzisiaj.
To mój domyślny stos dla nowych projektów:
Strategia sesji jest wspierana bazą danych (nie JWT), co daje mi natychmiastowe unieważnienie i proste zarządzanie sesjami.
// This is my typical auth.ts for a new project
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 has built-in passkey support
// This uses SimpleWebAuthn under the hood
}),
],
experimental: {
enableWebAuthn: true,
},
});Sięgam po zarządzanego dostawcę auth, gdy:
Kompromisy zarządzanych dostawców:
jose do JWT, @simplewebauthn/server do passkeys, bcrypt lub argon2 do haseł. Nigdy pisane ręcznie.crypto.randomBytes(32). UUID v4 jest akceptowalny, ale ma mniej entropii niż surowe losowe bajty.Skoro o tym mowa — oto jak prawidłowo hashować hasła w 2026:
import { hash, verify } from "@node-rs/argon2";
// Argon2id is the recommended algorithm
// These are reasonable defaults for a web application
async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 threads
});
}
async function verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
try {
return await verify(hashedPassword, password);
} catch {
return false;
}
}Dlaczego argon2id zamiast bcrypt? Argon2id jest memory-hard, co oznacza, że atakowanie go wymaga nie tylko mocy CPU, ale też dużych ilości RAM. To sprawia, że ataki GPU i ASIC są znacznie droższe. Bcrypt nadal jest w porządku — nie jest złamany — ale argon2id to lepsza opcja dla nowych projektów.
Przed wdrożeniem jakiegokolwiek systemu autentykacji, zweryfikuj:
HttpOnly, Secure, SameSite=Lax lub Strictalg)Referrer-Policy ustawionyTa lista nie jest wyczerpująca, ale pokrywa podatności, które najczęściej widziałem w systemach produkcyjnych.
Autentykacja to jedna z tych domen, gdzie krajobraz ciągle ewoluuje, ale fundamenty pozostają te same: weryfikuj tożsamość, wystawiaj minimalne niezbędne poświadczenia, sprawdzaj uprawnienia na każdej granicy i zakładaj naruszenie.
Największa zmiana w 2026 to passkeys wchodzące do mainstreamu. Wsparcie przeglądarek jest powszechne, wsparcie platform (iCloud Keychain, Google Password Manager) sprawia, że UX jest bezproblemowy, a właściwości bezpieczeństwa są naprawdę lepsze niż cokolwiek, co mieliśmy wcześniej. Jeśli budujesz nową aplikację, zrób passkeys swoją główną metodą logowania i traktuj hasła jako fallback.
Druga największa zmiana to fakt, że budowanie własnego auth stało się trudniejsze do uzasadnienia. Auth.js v5, Clerk i podobne rozwiązania poprawnie obsługują trudne części. Jedyny powód, żeby iść w custom, to gdy twoje wymagania naprawdę nie pasują do żadnego istniejącego rozwiązania — a to rzadsze, niż większość deweloperów myśli.
Cokolwiek wybierzesz, testuj swoje auth tak, jak zrobiłby to atakujący. Spróbuj powtarzać tokeny, fałszować podpisy, uzyskiwać dostęp do tras, do których nie powinieneś, i manipulować URL-ami przekierowań. Bugi, które znajdziesz przed wdrożeniem, to te, które nie trafią do wiadomości.