Nowoczesna autentykacja w 2026: JWT, sesje, OAuth i Passkeys
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.
Sesje vs JWT: Prawdziwe kompromisy#
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.
Autentykacja oparta na sesjach#
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:
- Natychmiastowe unieważnienie. Usuwasz rekord sesji i użytkownik jest wylogowany. Bez czekania na wygaśnięcie. To ma znaczenie, gdy wykryjesz podejrzaną aktywność.
- Widoczność sesji. Możesz pokazać użytkownikom ich aktywne sesje ("zalogowany na Chrome, Windows 11, Stambuł") i pozwolić im unieważniać poszczególne.
- Mały rozmiar cookie. Identyfikator sesji to zazwyczaj 64 znaki. Cookie nigdy nie rośnie.
- Kontrola po stronie serwera. Możesz aktualizować dane sesji (awansować użytkownika na admina, zmienić uprawnienia) i zacznie to obowiązywać przy następnym żądaniu.
Wady są również realne:
- Odpytanie bazy przy każdym żądaniu. Każde uwierzytelnione żądanie wymaga wyszukania sesji. Z Redis to poniżej milisekundy, ale to nadal zależność.
- Skalowanie horyzontalne wymaga współdzielonego magazynu. Jeśli masz wiele serwerów, wszystkie potrzebują dostępu do tego samego magazynu sesji. Sticky sessions to kruche obejście.
- CSRF jest zagrożeniem. Ponieważ cookies są wysyłane automatycznie, potrzebujesz ochrony CSRF. SameSite cookies w dużej mierze to rozwiązują, ale musisz rozumieć dlaczego.
Autentykacja oparta na JWT#
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://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;
}
}Zalety:
- Brak magazynu po stronie serwera. Token jest samowystarczalny. Weryfikujesz podpis i czytasz roszczenia. Bez odpytywania bazy.
- Działa między serwisami. W architekturze mikroserwisowej każdy serwis z kluczem publicznym może zweryfikować token. Nie potrzeba współdzielonego magazynu sesji.
- Bezstanowe skalowanie. Dodajesz więcej serwerów bez martwienia się o powiązanie sesji.
Wady — i to te, które ludzie pomijają:
- Nie możesz unieważnić JWT. Raz wystawiony, jest ważny do wygaśnięcia. Jeśli konto użytkownika zostanie skompromitowane, nie możesz wymusić wylogowania. Możesz zbudować blocklistę, ale wtedy przywróciłeś stan po stronie serwera i straciłeś główną zaletę.
- Rozmiar tokena. JWT z kilkoma roszczeniami to zazwyczaj 800+ bajtów. Dodaj role, uprawnienia i metadane, a wysyłasz kilobajty przy każdym żądaniu.
- Payload jest czytelny. Payload jest zakodowany w Base64, nie zaszyfrowany. Każdy może go zdekodować. Nigdy nie umieszczaj wrażliwych danych w JWT.
- Problemy z rozsynchronizowaniem zegarów. Jeśli twoje serwery mają różne zegary (to się zdarza), sprawdzanie wygaśnięcia staje się niewiarygodne.
Kiedy używać którego#
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.
JWT — głębsze spojrzenie#
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.
Anatomia JWT#
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.
RS256 vs HS256: To naprawdę ma znaczenie#
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.
Atak alg: none#
To najsłynniejsza podatność JWT i jest żenująco prosta. Niektóre biblioteki JWT kiedyś:
- Czytały pole
algz headera - Używały jakiegokolwiek algorytmu, który wskazywał
- Jeśli
alg: "none", pomijały weryfikację podpisu całkowicie
Atakują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.
Atak pomylenia algorytmów#
Powiązany z powyższym: jeśli serwer jest skonfigurowany do akceptowania RS256, atakujący może:
- Pobrać klucz publiczny serwera (jest publiczny, w końcu)
- Stworzyć token z
alg: "HS256" - Podpisać go używając klucza publicznego jako sekretu HMAC
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.
Rotacja refresh tokenów#
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:
- Access token: krótkotrwały (15 minut). Używany do żądań API.
- Refresh token: długotrwały (30 dni). Używany wyłącznie do pobrania nowego access tokena.
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 };
}Rotacja przy każdym użyciu#
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 };
}Dlaczego unieważnienie rodziny ma znaczenie#
Rozważ taki scenariusz:
- Użytkownik loguje się, dostaje refresh token A
- Atakujący kradnie refresh token A
- Atakujący używa A, żeby dostać nową parę (access token + refresh token B)
- Użytkownik próbuje użyć A (który nadal ma), żeby odświeżyć
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 OIDC#
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.
Kluczowa różnica#
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).
Authorization Code Flow z PKCE#
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,
};
}Trzy endpointy#
Każdy dostawca OAuth/OIDC udostępnia:
- Authorization endpoint — gdzie przekierowujesz użytkownika do logowania i udzielenia uprawnień. Zwraca authorization code.
- Token endpoint — gdzie twój serwer wymienia authorization code na access/refresh/ID tokeny. To wywołanie serwer-do-serwera.
- UserInfo endpoint — gdzie możesz pobrać dodatkowe dane profilu użytkownika za pomocą access tokena. Przy OIDC większość tego jest już w ID tokenie.
Parametr State#
Parametr state zapobiega atakom CSRF na callback OAuth. Bez niego:
- Atakujący rozpoczyna przepływ OAuth na swoim komputerze, dostaje authorization code
- Atakujący tworzy URL:
https://twojaapka.com/callback?code=KOD_ATAKUJACEGO - Atakujący oszukuje ofiarę, żeby kliknęła (link w mailu, ukryty obrazek)
- Twoja aplikacja wymienia kod atakującego i łączy konto Google atakującego z sesją ofiary
Z 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 (NextAuth) z Next.js App Router#
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ę.
Podstawowa konfiguracja#
// 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",
},
});Route Handler#
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;Ochrona 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");
// 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",
],
};Używanie sesji w Server Components#
// 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>
);
}Używanie sesji w Client Components#
"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 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.
Jak działają Passkeys#
Passkeys używają kryptografii klucza publicznego, wspieranej przez biometrię lub PIN urządzenia:
- Rejestracja: Przeglądarka generuje parę kluczy. Klucz prywatny zostaje na urządzeniu (w bezpiecznej enklawie, chroniony biometrią). Klucz publiczny jest wysyłany do twojego serwera.
- Autentykacja: Serwer wysyła wyzwanie (losowe bajty). Urządzenie podpisuje wyzwanie kluczem prywatnym (po weryfikacji biometrycznej). Serwer weryfikuje podpis przechowywanym kluczem publicznym.
Żaden współdzielony sekret nigdy nie przekracza sieci. Nie ma nic do wyłudzenia, nic do wycieku, nic do credential stuffingu.
Dlaczego Passkeys są odporne na phishing#
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.
Implementacja z SimpleWebAuthn#
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 = "akousa.net";
const rpID = "akousa.net";
const origin = "https://akousa.net";
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");
}
}
}Attestation vs Assertion#
Dwa terminy, na które natrafisz:
- Attestation (rejestracja): Proces tworzenia nowego poświadczenia. Authenticator "poświadcza" swoją tożsamość i możliwości. Dla większości aplikacji nie musisz weryfikować attestation — ustaw
attestationType: "none". - Assertion (autentykacja): Proces użycia istniejącego poświadczenia do podpisania wyzwania. Authenticator "potwierdza", że użytkownik jest tym, za kogo się podaje.
Implementacja MFA#
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 (Time-Based One-Time Passwords)#
TOTP to protokół stojący za Google Authenticator, Authy i 1Password. Działa tak:
- Serwer generuje losowy sekret (zakodowany w base32)
- Użytkownik skanuje kod QR zawierający sekret
- Zarówno serwer, jak i aplikacja authenticator obliczają ten sam 6-cyfrowy kod z sekretu i aktualnego czasu
- Kody zmieniają się co 30 sekund
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 = "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`;
}// 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());
}Kody zapasowe#
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;
}Przepływ odzyskiwania konta#
Odzyskiwanie MFA to część, którą większość tutoriali pomija, a większość prawdziwych aplikacji partaczy. Oto co implementuję:
- Pierwszorzędny: Kod TOTP z aplikacji authenticator
- Drugorzędny: Jeden z 10 kodów zapasowych
- Ostateczność: Odzyskiwanie przez email z 24-godzinnym okresem oczekiwania i powiadomieniem na inne zweryfikowane kanały użytkownika
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." };
}Wzorce autoryzacji#
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 vs ABAC#
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
}Zasada "Sprawdzaj na granicy"#
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.
Sprawdzanie uprawnień w middleware Next.js#
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();
});Typowe podatności#
To ataki, które najczęściej widzę w prawdziwych bazach kodu. Rozumienie ich jest niezbędne.
Session Fixation#
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;
}CSRF (Cross-Site Request Forgery)#
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.
Open Redirects w OAuth#
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 "/";
}
}Wyciek tokenów przez Referer#
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:
- Nigdy nie umieszczaj tokenów w parametrach URL do autentykacji
- Ustaw
Referrer-Policy: strict-origin-when-cross-origin(lub bardziej restrykcyjne) - Jeśli musisz umieszczać tokeny w URL-ach (np. linki weryfikacji email), zrób je jednorazowe i krótkotrwałe
// In your Next.js middleware or layout
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");JWT Key Injection#
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:
- Wygenerować własną parę kluczy
- Stworzyć JWT ze swoim payloadem i podpisać go swoim kluczem prywatnym
- Ustawić nagłówek
jwkwskazujący na swój klucz publiczny
Jeś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.
Mój stos auth w 2026#
Po latach budowania systemów autentykacji, oto czego faktycznie używam dzisiaj.
Dla większości projektów: Auth.js + PostgreSQL + Passkeys#
To mój domyślny stos dla nowych projektów:
- Auth.js (v5) do ciężkich zadań: dostawcy OAuth, zarządzanie sesjami, CSRF, adapter bazodanowy
- PostgreSQL z adapterem Prisma do przechowywania sesji i kont
- Passkeys przez SimpleWebAuthn jako główna metoda logowania dla nowych użytkowników
- Email/hasło jako fallback dla użytkowników, którzy nie mogą używać passkeys
- TOTP MFA jako drugi czynnik dla loginów opartych na hasłach
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,
},
});Kiedy użyć Clerk lub Auth0 zamiast tego#
Sięgam po zarządzanego dostawcę auth, gdy:
- Projekt potrzebuje enterprise SSO (SAML, SCIM). Poprawne zaimplementowanie SAML to wielomiesięczny projekt. Clerk robi to out of the box.
- Zespół nie ma ekspertyzy bezpieczeństwa. Jeśli nikt w zespole nie potrafi wyjaśnić PKCE, nie powinni budować auth od zera.
- Czas wejścia na rynek jest ważniejszy niż koszt. Auth.js jest darmowy, ale konfiguracja zabiera dni. Clerk zajmuje popołudnie.
- Potrzebujesz gwarancji compliance (SOC 2, HIPAA). Zarządzani dostawcy obsługują certyfikacje.
Kompromisy zarządzanych dostawców:
- Koszt: Clerk pobiera opłatę za miesięcznego aktywnego użytkownika. Na skalę to się sumuje.
- Vendor lock-in: Migracja z Clerk lub Auth0 jest bolesna. Twoja tabela użytkowników jest na ich serwerach.
- Limity kustomizacji: Jeśli twój przepływ auth jest nietypowy, będziesz walczył z opiniami dostawcy.
- Opóźnienie: Każde sprawdzenie auth idzie do zewnętrznego API. Z sesjami bazodanowymi to lokalne zapytanie.
Czego unikam#
- Tworzenia własnej kryptografii. Używam
josedo JWT,@simplewebauthn/serverdo passkeys,bcryptlubargon2do haseł. Nigdy pisane ręcznie. - Przechowywania haseł w SHA256. Używaj bcrypt (cost factor 12+) lub argon2id. SHA256 jest za szybkie — atakujący może sprawdzić miliardy hashy na sekundę z GPU.
- Długotrwałych access tokenów. Maksymalnie 15 minut. Używaj rotacji refresh tokenów do dłuższych sesji.
- Symetrycznych sekretów do weryfikacji między serwisami. Jeśli wiele serwisów musi weryfikować tokeny, użyj RS256 z parą kluczy publiczny/prywatny.
- Niestandardowych identyfikatorów sesji z niewystarczającą entropią. Używaj minimum
crypto.randomBytes(32). UUID v4 jest akceptowalny, ale ma mniej entropii niż surowe losowe bajty.
Hashowanie haseł: Prawidłowy sposób#
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.
Checklist bezpieczeństwa#
Przed wdrożeniem jakiegokolwiek systemu autentykacji, zweryfikuj:
- Hasła hashowane argon2id lub bcrypt (cost 12+)
- Sesje regenerowane po logowaniu (zapobiega session fixation)
- Cookies mają
HttpOnly,Secure,SameSite=LaxlubStrict - JWT jawnie określają algorytmy (nigdy nie ufaj nagłówkowi
alg) - Access tokeny wygasają w 15 minut lub mniej
- Rotacja refresh tokenów jest zaimplementowana z wykrywaniem ponownego użycia
- Parametr state OAuth jest weryfikowany (ochrona CSRF)
- URL-e przekierowań są walidowane względem allowlisty
- Rate limiting stosowany do logowania, rejestracji i resetowania hasła
- Nieudane próby logowania logowane z IP, ale nie z hasłami
- Blokada konta po N nieudanych próbach (z progresywnymi opóźnieniami, nie permanentną blokadą)
- Tokeny resetowania hasła jednorazowe i wygasające w 1 godzinę
- Kody zapasowe MFA hashowane jak hasła
- CORS skonfigurowany do akceptowania tylko znanych originów
- Nagłówek
Referrer-Policyustawiony - Brak wrażliwych danych w payloadach JWT (są czytelne dla każdego)
- Licznik WebAuthn weryfikowany i aktualizowany (zapobiega klonowaniu poświadczeń)
Ta lista nie jest wyczerpująca, ale pokrywa podatności, które najczęściej widziałem w systemach produkcyjnych.
Podsumowanie#
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.