
Kontekst: od klikalnego prototypu do produkcyjnego logowania
Dlaczego „działa u mnie” to za mało
Prototyp systemu logowania w Next zazwyczaj wygląda tak: prosty formularz, endpoint API, zapis użytkownika w bazie, porównanie hasła i gotowe. Na demo wszystko działa, zespół jest zadowolony, a potem aplikacja trafia do realnych użytkowników – razem z botami, skanerami bezpieczeństwa i całym zestawem kreatywnych ataków.
Różnica między prototypem a produkcyjnym systemem logowania polega na poziomie zaufania, jaki może mieć do niego organizacja. Prosty mechanizm „email + hasło w API Route” może wystarczyć na weekendowy hackathon, ale w projekcie komercyjnym staje się słabym ogniwem architektury. Dochodzą kwestie rotacji sekretów, zarządzania sesjami, blokad kont, prób siłowych, logowania zdarzeń i zgodności z wymogami prawnymi.
Bezpieczne logowanie w Next.js musi być projektowane jak osobny podsystem, a nie „prostka funkcja do obsługi formularza”. Na produkcji nie ma miejsca na zapisywanie haseł w logach, brak weryfikacji emaila czy brak czasu wygaśnięcia sesji. Każda z tych „oszczędności” wróci jak bumerang w najmniej wygodnym momencie – zwykle wtedy, gdy będziesz już mieć realnych użytkowników i dane, których nie chcesz oglądać w wyciekach.
Specyfika Next: SSR, RSC, API Routes i bezpieczeństwo
Next łączy w sobie kilka paradygmatów: SSR, statyczne generowanie, komponenty serwerowe (RSC), API Routes i route handlers oraz middleware. Dla systemu logowania oznacza to sporo możliwości, ale też kilka miejsc, w których można strzelić sobie w stopę.
Najważniejsze cechy z perspektywy bezpieczeństwa:
- SSR i server components – dostęp do sesji po stronie serwera bez wysyłania danych wrażliwych do klienta; świetna baza do bezpiecznej autoryzacji widoków.
- API Routes / route handlers – naturalne miejsce na obsługę logowania, rejestracji, odświeżania tokenów; tu implementuje się większość reguł bezpieczeństwa.
- Middleware – poziom „przed renderem”, gdzie można zablokować dostęp do sekcji serwisu, weryfikując sesję lub token JWT.
- Server actions – wygodne, ale wymagają dyscypliny; nie można w nich „bezmyślnie” opierać się na danych z formularza bez walidacji i ochrony CSRF.
Ta elastyczność sprawia, że system logowania w Next można zbudować bardzo poprawnie, ale równie łatwo pójść w stronę nieczytelnego miksu logiki po obu stronach, z rozproszonymi sprawdzeniami uprawnień.
Co prototypy pomijają i dlaczego to się mści
Typowy prototyp systemu logowania w Next.js pomija kilka kluczowych elementów, które na produkcji są niezbędne:
- Walidację wejścia – brak sensownej walidacji po stronie serwera, opieranie się tylko na walidacji client-side.
- Politykę haseł – „byleby miało 6 znaków” oraz brak sprawdzenia, czy hasło nie jest trywialne.
- Blokady i rate limiting – nieskończona liczba prób logowania, brak ochrony przed bruteforce.
- Weryfikację adresu e-mail – konta bez potwierdzenia, problem z SPAM-em i nadużyciami.
- Logowanie zdarzeń – brak audytu powoduje, że przy incydencie nie ma czego analizować.
- Mechanizm odzyskiwania konta – „reset hasła” dopisywany na końcu, często w pośpiechu i kiepsko.
Wszystkie te brakujące elementy wpływają na zaufanie do systemu, a co za tym idzie – na to, czy logowanie w Next można uznać za gotowe na produkcję. Prototyp służy do sprawdzenia, czy koncepcja ma sens. System produkcyjny musi wytrzymać rzeczywistość.
Co zaplanować przed napisaniem pierwszej linijki kodu
Zanim powstanie formularz logowania, trzeba odpowiedzieć na kilka niewygodnych – ale koniecznych – pytań architektonicznych:
- Jaki model sesji będzie używany: sesje serwerowe czy JWT w modelu stateless?
- Czy korzystasz z NextAuth / Auth.js, innej biblioteki, czy budujesz własny system?
- Czy wymagana jest weryfikacja e-maila przed logowaniem?
- Jak długo ma żyć sesja i czy użytkownik ma opcję „zapamiętaj mnie”?
- Jakie role i poziomy uprawnień będą potrzebne?
- Czy planowane są logowania społecznościowe (OAuth) i od razu też MFA?
- Gdzie i jak będą przechowywane sekrety (klucze JWT, salting, dane OAuth)?
Odpowiedzi na te pytania determinują wybór biblioteki, konfigurację bazy danych, a nawet strukturę folderów w projekcie Next. Trochę planowania na starcie oszczędza później bolesnych migracji z „na szybko skleconej” autoryzacji do czegoś, co prawnicy i pentesterzy są w stanie zaakceptować.

Podstawy architektury logowania w Next – jakie są opcje
Sesje serwerowe vs JWT – dwa główne modele
Projektując autoryzację i sesje w Next, najpierw trzeba wybrać podstawowy model:
- Sesje serwerowe – w ciasteczku przechowywany jest tylko identyfikator sesji, a właściwe dane sesji (ID użytkownika, role, timestampy) siedzą w bazie lub Redisie.
- JWT po stronie klienta – w ciasteczku lub localStorage trzymany jest podpisany token zawierający informacje o użytkowniku (sub, role, exp itd.).
Sposób pierwszy jest od lat standardem w klasycznych aplikacjach webowych, jest prosty, dobrze zrozumiały i bezpieczny, o ile poprawnie zabezpieczysz ciasteczka HTTP-only i połączenie HTTPS. Drugi jest kuszący swoją „statelessowością”, ale niesie konsekwencje: trudniejsze unieważnianie tokenów, ostrożne obchodzenie się z danymi wewnątrz JWT, zarządzanie tokenami odświeżania.
W wielu aplikacjach typu dashboard, panele administracyjne czy B2B, sesje serwerowe w Next będą bardziej naturalnym wyborem. JWT ma więcej sensu, gdy planujesz integrację z wieloma usługami, potrzebujesz SSO albo specyficznie mobilnego klienta komunikującego się z tym samym backendem.
Gdzie ulokować logikę logowania w Next
Next daje kilka miejsc, w których można umieścić logikę autoryzacji i sesji:
- API Routes / Route Handlers (app/api/…) – klasyczne endpointy REST do logowania, rejestracji, resetu hasła, tokenów odświeżania.
- Server actions – bezpośrednio przypięte do formularzy, mogą zapisywać w bazie i ustawiać ciasteczka, ale wymagają uważnego stosowania zabezpieczeń CSRF i walidacji.
- Middleware – działa przed wyrenderowaniem strony; idealne miejsce do sprawdzania, czy użytkownik ma ważną sesję, zanim dopuści się go na daną ścieżkę.
- Komponenty serwerowe – dzięki getServerSession / własnym helperom można wczytać użytkownika i jego rolę bezpośrednio na serwerze i dopiero wtedy zdecydować, co renderować.
Najbardziej czytelny wzorzec to:
- Wszelkie operacje mutujące (logowanie, rejestracja, reset) w API Routes / route handlers lub dobrze zorganizowanych server actions.
- Weryfikacja sesji i ról w middleware + wrażliwe API routes.
- Renderowanie z kontekstem użytkownika w komponentach serwerowych.
W ten sposób łatwiej utrzymać spójny system logowania w Next, zamiast rozproszonego kodu „sprawdzającego coś z grubsza” w wielu miejscach.
Gotowe biblioteki vs własne rozwiązanie
Na rynku są trzy główne podejścia do logowania w Next.js:
- NextAuth / Auth.js – dominujący standard, ogromna społeczność, szybkie podłączenie OAuth, wbudowana obsługa sesji, integracje z bazami.
- Lucia – lżejsza, modularna biblioteka auth, mniej „magii”, za to wymaga więcej ręcznego konfigurowania.
- Własny system – pełna kontrola, ale także pełna odpowiedzialność za bezpieczeństwo, rotacje kluczy, mechanizmy odświeżania tokenów i zgodność z dobrymi praktykami.
| Opcja | Zalety | Wady | Kiedy wybrać |
|---|---|---|---|
| NextAuth / Auth.js | Szybka konfiguracja, wiele providerów OAuth, dojrzały ekosystem | Dodatkowa warstwa abstrakcji, ograniczenia przy bardzo niestandardowych wymaganiach | Typowy SaaS, dashboard, aplikacje z logowaniem społecznościowym |
| Lucia | Większa kontrola, modularność, dobry balans między „gotowcem” a elastycznością | Mniejszy ekosystem, więcej pracy przy integracjach | Średnie i większe projekty, gdzie auth ma specyficzne wymagania |
| Własne rozwiązanie | Pełna kontrola nad modelem, procesami i bezpieczeństwem | Duża odpowiedzialność, potrzeba doświadczenia w bezpieczeństwie | Systemy krytyczne, bardzo specyficzne wymagania compliance |
W 90% przypadków NextAuth w konfiguracji produkcyjnej w zupełności wystarczy, a własne rozwiązanie ma sens tylko wtedy, gdy rozumiesz konsekwencje architektoniczne i masz realny powód, by nie używać gotowej biblioteki.
Dopasowanie do typu aplikacji
Nie każda aplikacja webowa wymaga tej samej architektury logowania. Dobrze jest powiązać wybór modelu sesji i biblioteki z typem projektu:
- SPA z Next + API backendowe – jeśli masz wyraźnie oddzielony backend, JWT może mieć sens, szczególnie przy wielu klientach (web, mobile).
- Dashboard / panel administracyjny – klasyczne sesje serwerowe w Next + NextAuth, ciasteczka HTTP-only, SSR.
- Publiczny serwis z kontami użytkowników – hybryda, ale nadal sesje serwerowe będą najbardziej naturalne.
- Aplikacje B2B – często wymagają SSO, integracji z IdP (Azure AD, Okta). Tutaj NextAuth i OAuth / OpenID Connect są pierwszym wyborem.
Wybierając architekturę dopasowaną do rodzaju aplikacji, ułatwiasz sobie życie przy skalowaniu, integracjach i ewentualnych audytach bezpieczeństwa.

Model danych użytkownika i przechowywanie haseł
Minimalny model użytkownika w produkcji
Nawet najprostszy system logowania potrzebuje sensownego modelu użytkownika. Minimalny, ale produkcyjny zestaw pól wygląda zwykle tak:
- id – unikalny identyfikator użytkownika (UUID lub int).
- email – unikalny adres e-mail, z indeksem.
- passwordHash – hash hasła (bcrypt, argon2).
- role – np. user, admin, opcjonalnie inne role domenowe.
- status – np. active, pending_email_verification, blocked.
- createdAt / updatedAt – znaczniki audytowe.
- lastLoginAt – opcjonalnie, ale przydatne do bezpieczeństwa i UX.
Dodatkowo dochodzą pola specyficzne dla logowań społecznościowych (np. provider, providerId) lub MFA. Kluczowe jest, by od początku zaplanować możliwość blokady konta i rozróżnienia kont weryfikowanych od nieweryfikowanych.
Hashowanie haseł: bcrypt i argon2
Hasła w bazie danych powinny być przechowywane wyłącznie jako hash, z użyciem algorytmu odpornego na ataki słownikowe i bruteforce. Najczęściej używa się:
- bcrypt – sprawdzony standard, biblioteki dla Node.js są dojrzałe, konfiguracja sprowadza się głównie do dobrania cost factor.
- argon2 – młodszy, ale bardzo solidny algorytm zwycięzca konkursu Password Hashing Competition; pozwala precyzyjnie sterować parametrami obliczeniowymi (czas, pamięć).
Dla aplikacji w Next z backendem na Node.js w praktyce często wygrywa bcrypt, bo jest szeroko wspierany i prosty w konfiguracji. Przykładowy proces rejestracji:
- Użytkownik wysyła hasło.
- Po stronie serwera generujesz salt (wewnątrz biblioteki bcrypt).
- Wyliczasz hash hasła z odpowiednim cost factor (np. 10–12 dla produkcji, w zależności od mocy serwera).
- Do bazy zapisujesz tylko hash, nigdy hasło w formie jawnej.
Walidacja danych i higiena formularzy logowania
Najbardziej spektakularne luki bezpieczeństwa często zaczynają się od banalnych formularzy. Zanim użytkownik w ogóle trafi do logiki sesji, trzeba uporządkować prostą rzecz: walidację danych.
Przy logowaniu i rejestracji sensownie jest rozdzielić dwa poziomy:
- Walidacja po stronie klienta – UX: format e-maila, minimalna długość hasła, komunikaty błędów w czasie rzeczywistym.
- Walidacja po stronie serwera – bezpieczeństwo: twarde reguły, których nie da się ominąć wyłączeniem JS.
W Next produkcyjnie dobrze sprawdza się podejście z jedną, wspólną warstwą walidacji (np. zod / yup) współdzieloną między klientem a serwerem.
// schemas/auth.ts
import { z } from "zod";
export const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128)
});
// app/api/login/route.ts
import { loginSchema } from "@/schemas/auth";
export async function POST(req: Request) {
const json = await req.json();
const parsed = loginSchema.safeParse(json);
if (!parsed.success) {
return new Response("Invalid credentials", { status: 400 });
}
const { email, password } = parsed.data;
// ...dalsza logika
}
Takie podejście eliminuje „rozjazd” między frontem a backendem: to samo wejście, ten sam schemat, inny kontekst użycia. Zmiana minimalnej długości hasła w jednym miejscu automatycznie wymusza ją wszędzie.
Polityka haseł a realne bezpieczeństwo
Wymóg hasła z 18 znakami, dwoma hieroglifami i cytatem z Szekspira niekoniecznie podniesie bezpieczeństwo, za to zacementuje karteczki pod klawiaturą. Zamiast ekstremalnych reguł lepiej oprzeć się o prosty zestaw:
- Minimalna długość (np. 10–12 znaków).
- Blokada haseł z listy oczywiście słabych (popularne hasła z wycieków).
- Brak wymuszania dziwacznych kombinacji znaków, jeśli hasło jest wystarczająco długie.
Technicznie można to ogarnąć tak:
- Lista zbanowanych haseł (np. lokalny bloom filter albo zewnętrzna usługa typu Have I Been Pwned).
- Prosty „password strength meter” po stronie klienta, ale bez twardego blokowania po stronie serwera na podstawie „siły”.
Zarządzanie sesją: czas życia i unieważnianie
Bez względu na to, czy używasz klasycznych sesji, czy JWT, trzeba zaprojektować dwa parametry:
- Czas życia sesji – ile trwa pojedyncze zalogowanie.
- Idle timeout – po jakim bezruchu sesja wygasa.
Przy prostych aplikacjach panelowych typowe ustawienia to:
- Całkowity czas życia: 7–30 dni.
- Idle timeout: 15–60 minut, z odświeżaniem przy aktywności.
W implementacji sesji serwerowych można zrealizować to tak:
- W tabeli
sessionsprzechowujeszcreatedAt,lastActiveAtiexpiresAt. - Przy każdym żądaniu:
- sprawdzasz, czy
expiresAt> teraz, - sprawdzasz, czy
lastActiveAt+ idleTimeout > teraz, - jeśli wszystko gra, aktualizujesz
lastActiveAt.
- sprawdzasz, czy
Dodatkowo warto mieć możliwość unieważnienia wszystkich sesji danego użytkownika (np. po zmianie hasła albo zgłoszeniu naruszenia):
- albo trzymasz
sessionVersionw tabeli użytkownika i w każdej sesji, - albo po prostu usuwasz wszystkie rekordy z
sessionspouserId.
Bezpieczne ciasteczka sesyjne
Ciasteczko to miejsce, w którym cała konstrukcja może się rozsypać. Przy produkcyjnym systemie logowania konfiguracja powinna być raczej konserwatywna niż „wygodna”.
- httpOnly – zawsze
truedla ciasteczek sesyjnych. Uniemożliwia dostęp z JS (ochrona przed XSS). - secure –
truew środowisku produkcyjnym (ciasteczko leci tylko po HTTPS). - sameSite – zazwyczaj
"lax"lub"strict"w klasycznych aplikacjach;"none"wyłącznie, gdy potrzebujesz cross-site (i wtedy wymagane jestsecure). - domain / path – zawężone możliwie mocno (np. domena główna bez zbędnych subdomen).
// app/api/login/route.ts
import { cookies } from "next/headers";
export async function POST(req: Request) {
// ...
const cookieStore = cookies();
cookieStore.set("session", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7 // 7 dni
});
return new Response(null, { status: 204 });
}
Jeśli korzystasz z JWT w ciasteczkach, ustawienia pozostają podobne, ale pojawia się dodatkowa kwestia: nie pakuj do JWT poufnych danych (np. numerów dokumentów) – token jest co prawda podpisany, ale nie zaszyfrowany.
Ochrona przed CSRF w kontekście Next
Przy logowaniu opartym o ciasteczka trzeba liczyć się z CSRF. Nawet gdy używasz sameSite=lax, w wielu scenariuszach to wciąż za mało. Minimalny zestaw zabezpieczeń to:
- oddzielenie mutujących endpointów (POST/PUT/PATCH/DELETE) od GET,
- wymóg customowego nagłówka przy żądaniach AJAX (np.
X-Requested-With), - tokeny CSRF dla formularzy renderowanych z serwera.
W wariancie z klasycznym formularzem logowania (SSR) mechanizm może wyglądać następująco:
- Na stronie logowania generujesz losowy token CSRF i zapisujesz go w httpOnly cookie + w sesji serwerowej.
- W formularzu umieszczasz token w ukrytym polu (wartość przekazana z serwera).
- Endpoint logowania porównuje token z formularza z tym zapisanym po stronie serwera.
// app/login/page.tsx (server component)
import { cookies } from "next/headers";
import { randomBytes } from "crypto";
export default async function LoginPage() {
const csrfToken = randomBytes(32).toString("hex");
cookies().set("csrf", csrfToken, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/"
});
return (
<form method="POST" action="/api/login">
<input type="hidden" name="csrfToken" value={csrfToken} />
{/* ...pola logowania... */}
</form>
);
}
Przy żądaniach wysyłanych wyłącznie przez fetch/Axios z własnej domeny i odpowiednim sameSite można czasem obejść się bez pełnego CSRF, ale wymaga to bardzo świadomego modelu zagrożeń.
Bezpieczne renderowanie UI w oparciu o rolę
Kontrola dostępu to nie tylko middleware i API. UI też może „przeciekać” informacje. Lepiej, żeby przycisk „Usuń firmę” w panelu B2B w ogóle nie renderował się dla zwykłego użytkownika, niż żeby pojawiał się z disabled i kusił do eksploracji DOM-u.
W Next wygodny wzorzec to mały helper na serwerze, który ładuje sesję i użytkownika, a potem podaje je komponentom serwerowym:
// lib/auth.ts
import { cookies } from "next/headers";
import { db } from "./db";
export async function getCurrentUser() {
const cookieStore = cookies();
const sessionId = cookieStore.get("session")?.value;
if (!sessionId) return null;
const session = await db.session.findUnique({
where: { id: sessionId },
include: { user: true }
});
if (!session) return null;
return session.user;
}
// app/dashboard/page.tsx
import { getCurrentUser } from "@/lib/auth";
export default async function DashboardPage() {
const user = await getCurrentUser();
if (!user) {
// Opcja: redirect do /login
return null;
}
const isAdmin = user.role === "admin";
return (
<section>
<h1>Dashboard</h1>
{isAdmin && (
<button className="danger">Usuń organizację</button>
)}
</section>
);
}
Sam UI to oczywiście za mało: wszystkie operacje w API muszą i tak sprawdzać rolę i uprawnienia. UI jest tylko pierwszą linią „nie kusić użytkownika nie tym, co trzeba”.
Bezpieczne logowanie i rejestracja z użyciem server actions
Server actions są kuszące przy prostych formularzach. Można jednak bardzo szybko stworzyć coś, co będzie działało świetnie… do momentu pierwszego audytu bezpieczeństwa. Kilka zasad, które pomagają utrzymać się po właściwej stronie:
- Każda action powinna weryfikować pochodzenie żądania (origin / referer) przy operacjach wrażliwych.
- Przy logowaniu i rejestracji lepiej dodać CSRF token, nawet jeśli sam Next częściowo pomaga.
- Nie przekazuj z action do klienta poufnych danych – zwłaszcza całego obiektu użytkownika z hashami, flagami bezpieczeństwa itp.
// app/login/LoginForm.tsx
"use client";
import { useFormState } from "react-dom";
import { loginAction } from "./actions";
export default function LoginForm() {
const [state, formAction] = useFormState(loginAction, { error: null });
return (
<form action={formAction}>
<input type="email" name="email" required />
<input type="password" name="password" required />
{state.error && <p className="error">{state.error}</p>}
<button type="submit">Zaloguj</button>
</form>
);
}
// app/login/actions.ts
"use server";
import { cookies, headers } from "next/headers";
import { loginSchema } from "@/schemas/auth";
import { db } from "@/lib/db";
import bcrypt from "bcryptjs";
export async function loginAction(
prevState: { error: string | null },
formData: FormData
) {
const origin = headers().get("origin");
if (!origin || !origin.endsWith(process.env.APP_DOMAIN!)) {
return { error: "Invalid origin" };
}
const raw = {
email: formData.get("email"),
password: formData.get("password")
};
const parsed = loginSchema.safeParse(raw);
if (!parsed.success) {
return { error: "Invalid credentials" };
}
const { email, password } = parsed.data;
const user = await db.user.findUnique({ where: { email } });
if (!user || !user.passwordHash) {
// Nie zdradzamy, czy mail istnieje
return { error: "Invalid credentials" };
}
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
return { error: "Invalid credentials" };
}
// Tutaj tworzysz sesję, ustawiasz cookie itd.
cookies().set("session", "new-session-id", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/"
});
return { error: null };
}
Ograniczanie prób logowania i blokady konta
Bruteforce na formularzach logowania nie jest kwestią „czy”, tylko „kiedy ktoś spróbuje”. Zamiast nadmiernego optymizmu lepiej mieć od razu:
- rate limiting na IP / fingerprint,
- licznik nieudanych prób per konto,
- mechanizm czasowej blokady po zbyt wielu błędnych próbach.
W Next często stosuje się mieszankę:
- Rate limit na poziomie edge / middleware albo zewnętrznego reverse proxy (Cloudflare, nginx, API Gateway).
- Per-user lock w bazie: kolumny typu
failedLoginAttempts,lastFailedLoginAt,lockedUntil.
// logika wewnątrz endpointu / action logowania
const MAX_ATTEMPTS = 5;
const LOCK_TIME_MINUTES = 15;
if (user.lockedUntil && user.lockedUntil > new Date()) {
return { error: "Account temporarily locked" };
}
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
const failed = user.failedLoginAttempts + 1;
const lockedUntil =
failed >= MAX_ATTEMPTS
? new Date(Date.now() + LOCK_TIME_MINUTES * 60 * 1000)
: null;
await db.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: failed,
lockedUntil
}
});
return { error: "Invalid credentials" };
}
// Reset licznika przy udanym logowaniu
await db.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: 0,
lockedUntil: null
}
});
Przy systemach z dużą liczbą użytkowników dobrze dodać monitoring takich blokad – nawet prosta tablica w panelu admina: „Top 10 kont z największą liczbą nieudanych logowań”. To często pierwsze miejsce, w którym widać kampanię ataków.
Reset hasła i linki jednorazowe
Reset hasła i linki jednorazowe w praktyce
Reset hasła to klasyczny element ataków: od enumeracji użytkowników po przejęcie konta przez zgadywanie tokenów. Same „magiczne linki” też potrafią być pułapką, jeśli nie są odpowiednio krótkotrwałe i losowe.
Bezpieczny proces resetu można sprowadzić do kilku kroków:
- Użytkownik podaje e‑mail na stronie „Zapomniałem hasła”.
- Serwer, jeśli użytkownik istnieje, generuje jednorazowy token, zapisuje go w bazie (lub jego hash) i wysyła link e‑mailem. Odpowiedź HTTP jest identyczna, niezależnie od tego, czy konto istnieje.
- Użytkownik klika link. Backend weryfikuje token (ważność, zużycie, powiązanie z kontem), prezentuje formularz ustawienia nowego hasła.
- Po ustawieniu nowego hasła token jest oznaczany jako użyty (lub usuwany), a stare sesje mogą zostać unieważnione.
// db schema (przykład z Prisma)
model PasswordResetToken {
id String @id @default(cuid())
tokenHash String @unique
userId String
user User @relation(fields: [userId], references: [id])
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
}
// lib/tokens.ts
import crypto from "crypto";
import bcrypt from "bcryptjs";
export function generateResetToken() {
const rawToken = crypto.randomBytes(32).toString("hex");
return rawToken;
}
export async function hashToken(token: string) {
return bcrypt.hash(token, 10);
}
// app/api/password-reset/request/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { generateResetToken, hashToken } from "@/lib/tokens";
import { sendPasswordResetEmail } from "@/lib/mailer";
export async function POST(req: NextRequest) {
const { email } = await req.json().catch(() => ({ email: null }));
if (!email || typeof email !== "string") {
return NextResponse.json({ ok: true }); // nie zdradzamy zbyt wiele
}
const user = await db.user.findUnique({ where: { email } });
// Odpowiedź zawsze taka sama
if (!user) {
return NextResponse.json({ ok: true });
}
const rawToken = generateResetToken();
const tokenHash = await hashToken(rawToken);
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1h
await db.passwordResetToken.create({
data: {
tokenHash,
userId: user.id,
expiresAt
}
});
const resetUrl = `${process.env.APP_URL}/reset-password?token=${encodeURIComponent(
rawToken
)}`;
await sendPasswordResetEmail(user.email, resetUrl);
return NextResponse.json({ ok: true });
}
Przy weryfikacji tokenu nie warto trzymać go w bazie w formie jawnej. Ten sam trik co przy hasłach: przechowywany jest tylko hash, a porównanie odbywa się z użyciem bcrypt.compare.
// app/reset-password/page.tsx
import ResetPasswordForm from "./ResetPasswordForm";
export default function ResetPasswordPage({
searchParams
}: {
searchParams: { token?: string };
}) {
// Token trafia do formularza w polu hidden,
// ale weryfikacja i tak odbędzie się na serwerze.
const token = searchParams.token || "";
return <ResetPasswordForm token={token} />;
}
// app/reset-password/ResetPasswordForm.tsx
"use client";
import { useFormState } from "react-dom";
import { resetPasswordAction } from "./actions";
export default function ResetPasswordForm({ token }: { token: string }) {
const [state, formAction] = useFormState(resetPasswordAction, { error: null, success: false });
if (state.success) {
return <p>Hasło zostało zmienione. Możesz się zalogować.</p>;
}
return (
<form action={formAction}>
<input type="hidden" name="token" value={token} />
<input type="password" name="password" required minLength={12} />
<input type="password" name="passwordConfirm" required minLength={12} />
{state.error && <p className="error">{state.error}</p>}
<button type="submit">Ustaw nowe hasło</button>
</form>
);
}
// app/reset-password/actions.ts
"use server";
import { headers } from "next/headers";
import bcrypt from "bcryptjs";
import { db } from "@/lib/db";
import { resetPasswordSchema } from "@/schemas/auth";
import { hashPassword } from "@/lib/passwords";
export async function resetPasswordAction(
prevState: { error: string | null; success: boolean },
formData: FormData
) {
const origin = headers().get("origin");
if (!origin || !origin.endsWith(process.env.APP_DOMAIN!)) {
return { error: "Invalid origin", success: false };
}
const raw = {
token: formData.get("token"),
password: formData.get("password"),
passwordConfirm: formData.get("passwordConfirm")
};
const parsed = resetPasswordSchema.safeParse(raw);
if (!parsed.success) {
return { error: "Invalid payload", success: false };
}
const { token, password } = parsed.data;
const allTokens = await db.passwordResetToken.findMany({
where: { usedAt: null, expiresAt: { gt: new Date() } },
include: { user: true }
});
// Słowo-klucz: nie szukamy tokenu po plain text, tylko porównujemy hashe
let matchedToken = null as (typeof allTokens)[number] | null;
for (const t of allTokens) {
const match = await bcrypt.compare(token, t.tokenHash);
if (match) {
matchedToken = t;
break;
}
}
if (!matchedToken) {
return { error: "Invalid or expired token", success: false };
}
const newPasswordHash = await hashPassword(password);
await db.$transaction(async (tx) => {
await tx.user.update({
where: { id: matchedToken!.userId },
data: { passwordHash: newPasswordHash }
});
await tx.passwordResetToken.update({
where: { id: matchedToken!.id },
data: { usedAt: new Date() }
});
// (Opcjonalnie) unieważnienie wszystkich sesji użytkownika
await tx.session.deleteMany({
where: { userId: matchedToken!.userId }
});
});
return { error: null, success: true };
}
W systemach o bardzo dużej skali bardziej opłaca się przechowywać skrót tokenu z dodatkowymi danymi (np. prefix), żeby uniknąć liniowego przeszukiwania. Kluczowe pozostaje jedno: token nie może zdradzać ID użytkownika ani być przewidywalny.
Bezpieczne „magic linki” i logowanie bez hasła
Logowanie magic linkiem wygląda podobnie jak reset hasła, tylko zamiast zmieniać hasło, tworzysz sesję i przekierowujesz do panelu. Brzmi wygodnie, ale jeśli token jest zbyt długi czas ważny albo nie jest związany z urządzeniem, otwierasz furtkę do przejęcia konta przez podsłuchany mail.
Praktyczny wariant:
- Token jednorazowy, ważny krótko (5–15 minut).
- Powiązanie z adresem IP / user-agenta jako dodatkowy sygnał (soft check, raczej do monitoringu niż hard blocka).
- Po użyciu tokenu – natychmiastowa invalidacja.
// db schema
model MagicLinkToken {
id String @id @default(cuid())
tokenHash String @unique
userId String
user User @relation(fields: [userId], references: [id])
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
}
// app/api/magic-link/request/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { generateResetToken, hashToken } from "@/lib/tokens";
import { sendMagicLinkEmail } from "@/lib/mailer";
export async function POST(req: NextRequest) {
const { email } = await req.json().catch(() => ({ email: null }));
if (!email || typeof email !== "string") {
return NextResponse.json({ ok: true });
}
const user = await db.user.findUnique({ where: { email } });
if (!user) {
return NextResponse.json({ ok: true });
}
const rawToken = generateResetToken();
const tokenHash = await hashToken(rawToken);
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minut
await db.magicLinkToken.create({
data: { tokenHash, userId: user.id, expiresAt }
});
const link = `${process.env.APP_URL}/magic-login?token=${encodeURIComponent(
rawToken
)}`;
await sendMagicLinkEmail(user.email, link);
return NextResponse.json({ ok: true });
}
// app/magic-login/route.ts (route handler zamiast page, do redirectu)
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { db } from "@/lib/db";
import { cookies } from "next/headers";
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get("token");
if (!token) {
return NextResponse.redirect("/login?error=invalid-link");
}
const tokens = await db.magicLinkToken.findMany({
where: { usedAt: null, expiresAt: { gt: new Date() } },
include: { user: true }
});
let matched = null as (typeof tokens)[number] | null;
for (const t of tokens) {
const ok = await bcrypt.compare(token, t.tokenHash);
if (ok) {
matched = t;
break;
}
}
if (!matched) {
return NextResponse.redirect("/login?error=invalid-link");
}
await db.$transaction(async (tx) => {
await tx.magicLinkToken.update({
where: { id: matched!.id },
data: { usedAt: new Date() }
});
// Tworzenie sesji
const session = await tx.session.create({
data: {
userId: matched!.userId,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
}
});
cookies().set("session", session.id, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/"
});
});
return NextResponse.redirect("/dashboard");
}
Przy magic linkach dobrze działa prosty limit: maksymalnie kilka aktywnych tokenów na użytkownika. Resztę można automatycznie czyścić, żeby nie mieć „archiwum linków”, które ktoś odzyska ze starego backupu skrzynki.
Integracja 2FA / MFA w Next
Dwuskładnikowe logowanie to następny krok po „zdrowym” haśle. W środowisku B2B wciąż sporo osób traktuje je jak upgrade „na potem”, który nigdy nie nadchodzi. A w Next integracja TOTP (Google Authenticator, Authy itd.) jest relatywnie prosta.
Scenariusz ustawiania 2FA:
- Użytkownik w panelu bezpieczeństwa prosi o włączenie 2FA.
- Serwer generuje secret TOTP, zapisuje jego zaszyfrowaną wersję w bazie, zwraca QR code (np. jako data URL).
- Użytkownik skanuje QR w aplikacji TOTP, wpisuje wygenerowany kod weryfikacyjny.
- Jeśli kod jest poprawny, flaga
twoFactorEnabledw bazie zmienia się natrue.
// lib/totp.ts
import * as speakeasy from "speakeasy";
export function generateTotpSecret(email: string) {
return speakeasy.generateSecret({
name: `MojaApp (${email})`,
length: 20
});
}
export function verifyTotp(token: string, secret: string) {
return speakeasy.totp.verify({
secret,
encoding: "base32",
token,
window: 1
});
}
// app/api/2fa/setup/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { generateTotpSecret } from "@/lib/totp";
export async function POST(req: NextRequest) {
const user = await getCurrentUser();
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const secret = generateTotpSecret(user.email);
// Tu warto zaszyfrować secret (np. z użyciem AES i klucza z env)
await db.user.update({
where: { id: user.id },
data: {
twoFactorTempSecret: secret.base32
}
});
return NextResponse.json({
otpauthUrl: secret.otpauth_url // klient zrobi z tego QR
});
}
// app/api/2fa/activate/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
import { verifyTotp } from "@/lib/totp";
export async function POST(req: NextRequest) {
const user = await getCurrentUser();
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { code } = await req.json().catch(() => ({ code: null }));
if (!code || typeof code !== "string") {
return new NextResponse("Invalid payload", { status: 400 });
}
const freshUser = await db.user.findUnique({
where: { id: user.id }
});
if (!freshUser?.twoFactorTempSecret) {
return new NextResponse("No 2FA setup in progress", { status: 400 });
}
const valid = verifyTotp(code, freshUser.twoFactorTempSecret);
if (!valid) {
return new NextResponse("Invalid code", { status: 400 });
}
await db.user.update({
where: { id: user.id },
data: {
twoFactorSecret: freshUser.twoFactorTempSecret,
twoFactorTempSecret: null,
twoFactorEnabled: true
}
});
return NextResponse.json({ ok: true });
}
Przy logowaniu z 2FA endpoint / action nie kończy sesji po samym haśle. Raczej tworzy „półsesję” albo zapisuje w stanie, że użytkownik przeszedł 1. krok, i dopiero po poprawnym TOTP ustawia właściwy cookie sesyjny.
// app/login/actions.ts (fragment z 2FA)
if (user.twoFactorEnabled) {
// Tworzymy tymczasowy "challenge"
const challenge = await db.loginChallenge.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 5 * 60 * 1000)
}
});
// W odpowiedzi do UI nie wysyłamy detali użytkownika
return { error: null, challengeId: challenge.id };
}
// Brak 2FA - normalne logowanie
// app/api/2fa/verify-login/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { verifyTotp } from "@/lib/totp";
import { cookies } from "next/headers";
export async function POST(req: NextRequest) {
const { challengeId, code } = await req.json().catch(() => ({}));
if (!
Najczęściej zadawane pytania (FAQ)
Jak najlepiej zaimplementować bezpieczne logowanie w Next.js – od czego zacząć?
Na start trzeba podjąć kilka decyzji architektonicznych: model sesji (sesje serwerowe vs JWT), wybór biblioteki (NextAuth/Auth.js, Lucia czy własne rozwiązanie) oraz sposób przechowywania sekretów (ENV, manager sekretów w chmurze). Bez tego nawet najładniejszy formularz logowania będzie tylko drogim prototypem.
Następnie warto wydzielić miejsca w kodzie: operacje mutujące (logowanie, rejestracja, reset hasła) do API Routes / route handlers lub dobrze zabezpieczonych server actions, weryfikację sesji do middleware, a logikę uprawnień do helperów używanych w komponentach serwerowych. Dzięki temu system logowania jest spójny, a nie rozsiany po losowych hookach w kliencie.
Czy w Next.js lepiej użyć sesji serwerowych czy JWT do logowania?
W większości klasycznych aplikacji webowych (dashboardy, panele B2B, aplikacje SaaS) bezpieczniejszym i prostszym wyborem są sesje serwerowe. W ciasteczku trzymasz tylko identyfikator sesji, a właściwe dane (ID użytkownika, role, timestampy) przechowujesz w bazie lub Redisie. W połączeniu z ciasteczkami HTTP-only i HTTPS daje to solidny, przewidywalny model.
JWT ma sens, gdy potrzebujesz integracji z wieloma usługami, SSO lub gdy ten sam backend obsługuje natywną apkę mobilną. Trzeba jednak liczyć się z trudniejszym unieważnianiem tokenów, obsługą tokenów odświeżania i bardzo ostrożnym umieszczaniem danych w tokenie. Jeśli nie masz twardego powodu biznesowego na JWT – sesje serwerowe zwykle wygrywają.
Gdzie umieścić logikę logowania w Next.js: API Routes, middleware czy server actions?
Podstawowa zasada: wszystko, co zmienia stan (logowanie, rejestracja, reset hasła, odświeżanie tokenów), powinno trafić do API Routes / route handlers lub starannie zaprojektowanych server actions. Tam robisz walidację, sprawdzasz hasło, ustawiasz ciasteczka i zapisujesz dane w bazie.
Middleware służy do weryfikacji – przed wyrenderowaniem strony sprawdza, czy użytkownik ma ważną sesję lub odpowiednią rolę. Komponenty serwerowe z kolei pobierają sesję (np. przez getServerSession) i na tej podstawie decydują, co użytkownik widzi. Taki podział minimalizuje „magiczny” kod w kliencie i ułatwia audyt bezpieczeństwa.
Jakie błędy bezpieczeństwa najczęściej pojawiają się w prototypach logowania w Next.js?
Lista hitów jest niestety dość stała: brak walidacji po stronie serwera (poleganie na frontendzie), zbyt słaba polityka haseł, brak limitu prób logowania i blokad kont, brak weryfikacji adresu e-mail oraz zupełny brak logowania zdarzeń. Często też mechanizm resetu hasła jest dopisywany „na wczoraj” i działa na granicy zdrowego rozsądku.
Efekt jest taki, że prototyp działa świetnie na demo, ale przy pierwszym starciu z botami, skanerami bezpieczeństwa i prawdziwymi użytkownikami zaczyna się festiwal problemów. Dlatego lepiej od razu założyć: prototyp służy do sprawdzenia koncepcji, a produkcja wymaga pełnego zestawu zabezpieczeń, logów i sensownej polityki sesji.
NextAuth (Auth.js), Lucia czy własne rozwiązanie – co wybrać do logowania w Next.js?
NextAuth / Auth.js to domyślny wybór dla większości projektów: szybka konfiguracja, wbudowane sesje, masa providerów OAuth i dojrzały ekosystem. Sprawdza się w typowych SaaS-ach, dashboardach i wszędzie tam, gdzie logowanie ma być „znanym standardem”, a nie pionierskim eksperymentem.
Lucia daje więcej kontroli i mniej „magii”, ale wymaga lepszego zrozumienia mechanizmów autoryzacji. Dobrze pasuje do projektów, które są nietypowe, ale nadal nie chcą od zera wymyślać bezpieczeństwa. Własne rozwiązanie ma sens tylko wtedy, gdy masz bardzo specyficzne wymagania lub zespół z doświadczeniem w bezpieczeństwie – bo razem z wolnością dostajesz też pełną odpowiedzialność: od rotacji kluczy po obsługę podejrzanych logowań.
Jak zabezpieczyć formularz logowania w Next.js przed atakami bruteforce i CSRF?
Przeciwko bruteforce pomaga połączenie kilku technik zamiast jednego „magicznego” ifa. Typowy zestaw to: rate limiting na IP / użytkownika, czasowe blokady konta po serii nieudanych prób, captcha przy podejrzanej aktywności i logowanie prób z możliwością ich analizy. W Next.js logika ta ląduje zwykle w route handlers albo w warstwie proxy/edge przed aplikacją.
CSRF ograniczasz przez stosowanie tokenów CSRF (szczególnie przy server actions), trzymanie sesji w ciasteczkach HTTP-only, wymuszanie HTTPS i poprawną konfigurację SameSite. Warto też rozdzielić domeny dla części publicznej i panelu administracyjnego – wtedy potencjalny atakujący ma trudniej, zanim cokolwiek „wyklika”.
Jak długo powinna trwać sesja użytkownika i czy opcja „zapamiętaj mnie” jest bezpieczna?
Długość sesji zależy od typu aplikacji i wrażliwości danych. Dla panelu administracyjnego sensowny jest krótki czas życia sesji (np. godziny) z automatycznym wygaszeniem po bezczynności. Dla klasycznego SaaS-a można wydłużyć sesję, ale w połączeniu z monitorowaniem podejrzanych logowań, logami i możliwością ręcznego unieważnienia sesji.
Opcja „zapamiętaj mnie” jest bezpieczna, jeśli stoi za nią osobny, długotrwały, ale dobrze zabezpieczony mechanizm (np. refresh token w ciasteczku HTTP-only z jasną polityką odwoływania). Najgorszy scenariusz to „zapamiętaj mnie” z tokenem bez wygaśnięcia, wrzuconym do localStorage. To już lepiej w ogóle nie zapamiętywać, niż zapamiętać w taki sposób.
Co warto zapamiętać
- System logowania w Next nie może być „prosta funkcja do formularza” – to osobny podsystem, który musi uwzględniać rotację sekretów, zarządzanie sesjami, blokady kont, zgodność prawną i porządne logowanie zdarzeń.
- Różne mechanizmy Next (SSR, server components, API Routes, middleware, server actions) dają świetne narzędzia do bezpiecznego logowania, ale łatwo skończyć z chaosem i rozproszoną logiką uprawnień, jeśli nie zaplanuje się architektury.
- Prototypy pomijają kluczowe elementy bezpieczeństwa: walidację wejścia po stronie serwera, sensowną politykę haseł, rate limiting i blokady, weryfikację e-maila, audyt logowań oraz bezpieczny reset hasła – a to dokładnie te miejsca, które atakujący lubią najbardziej.
- Przed pierwszą linijką kodu trzeba odpowiedzieć na twarde pytania: model sesji (serwerowe vs JWT), wybór biblioteki (NextAuth/Auth.js czy własne rozwiązanie), wymagania co do weryfikacji e-maila, długości życia sesji, ról, OAuth/MFA oraz sposobu przechowywania sekretów.
- Sesje serwerowe w ciasteczku HTTP-only są często najlepszym i najprostszym wyborem dla typowych paneli, dashboardów czy aplikacji B2B, podczas gdy JWT ma sens głównie przy integracjach między usługami, SSO i bardziej rozproszonych architekturach.
- JWT „stateless” brzmi kusząco, ale komplikuje unieważnianie sesji, rotację tokenów i bezpieczeństwo danych wewnątrz tokenu; jeżeli potrzebny jest głównie klasyczny login do aplikacji webowej, to jest to zwykle przekombinowanie.






