Autenticazione moderna nel 2026: JWT, sessioni, OAuth e passkey
Il panorama completo dell'autenticazione: quando usare sessioni vs JWT, flussi OAuth 2.0 / OIDC, rotazione dei refresh token, passkey (WebAuthn) e i pattern auth per Next.js che uso davvero.
L'autenticazione è l'unica area dello sviluppo web dove "funziona" non basta mai. Un bug nel tuo date picker è fastidioso. Un bug nel tuo sistema di auth è un data breach.
Ho implementato l'autenticazione da zero, migrato tra provider, debuggato incidenti di furto di token e gestito le conseguenze delle decisioni "sistemiamo la sicurezza dopo." Questo post è la guida completa che avrei voluto avere quando ho iniziato. Non solo la teoria — i veri compromessi, le vulnerabilità reali e i pattern che reggono sotto pressione in produzione.
Copriremo l'intero panorama: sessioni, JWT, OAuth 2.0, passkey, MFA e autorizzazione. Alla fine, capirai non solo come funziona ogni meccanismo, ma quando usarlo e perché le alternative esistono.
Sessioni vs JWT: i veri compromessi#
Questa è la prima decisione che affronterai, e internet è pieno di cattivi consigli al riguardo. Lascia che ti esponga cosa conta davvero.
Autenticazione basata su sessioni#
Le sessioni sono l'approccio originale. Il server crea un record di sessione, lo memorizza da qualche parte (database, Redis, memoria), e dà al client un session ID opaco in un cookie.
// Creazione semplificata della sessione
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 ore
ipAddress: request.headers.get("x-forwarded-for") ?? "unknown",
userAgent: request.headers.get("user-agent") ?? "unknown",
};
// Salva nel database o 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;
}I vantaggi sono reali:
- Revoca istantanea. Cancella il record di sessione e l'utente è disconnesso. Nessuna attesa per la scadenza. Questo è importante quando rilevi attività sospette.
- Visibilità delle sessioni. Puoi mostrare agli utenti le loro sessioni attive ("connesso su Chrome, Windows 11, Istanbul") e permettere di revocarle individualmente.
- Dimensione cookie ridotta. Il session ID è tipicamente di 64 caratteri. Il cookie non cresce mai.
- Controllo server-side. Puoi aggiornare i dati di sessione (promuovere un utente ad admin, cambiare i permessi) e l'effetto è immediato alla prossima richiesta.
Gli svantaggi sono altrettanto reali:
- Hit al database a ogni richiesta. Ogni richiesta autenticata necessita di un lookup di sessione. Con Redis è sotto il millisecondo, ma rimane comunque una dipendenza.
- Lo scaling orizzontale richiede storage condiviso. Se hai più server, tutti devono accedere allo stesso session store. Le sticky session sono un workaround fragile.
- CSRF è una preoccupazione. Poiché i cookie vengono inviati automaticamente, hai bisogno di protezione CSRF. I cookie SameSite risolvono in gran parte questo problema, ma devi capire il perché.
Autenticazione basata su JWT#
I JWT invertono il modello. Invece di memorizzare lo stato della sessione sul server, lo codifichi in un token firmato che il client detiene.
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;
}
}I vantaggi:
- Nessuno storage server-side. Il token è autocontenuto. Verifichi la firma e leggi i claim. Nessun hit al database.
- Funziona tra servizi. In un'architettura microservizi, qualsiasi servizio con la chiave pubblica può verificare il token. Nessun session store condiviso necessario.
- Scaling stateless. Aggiungi più server senza preoccuparti dell'affinità di sessione.
Gli svantaggi — e questi sono quelli che la gente sorvola:
- Non puoi revocare un JWT. Una volta emesso, è valido fino alla scadenza. Se l'account di un utente è compromesso, non puoi forzare il logout. Puoi costruire una blocklist, ma allora hai reintrodotto lo stato server-side e perso il vantaggio principale.
- Dimensione del token. JWT con qualche claim sono tipicamente 800+ byte. Aggiungi ruoli, permessi e metadati e stai inviando kilobyte ad ogni richiesta.
- Il payload è leggibile. Il payload è codificato in Base64, non crittografato. Chiunque può decodificarlo. Non mettere mai dati sensibili in un JWT.
- Problemi di clock skew. Se i tuoi server hanno orologi diversi (succede), i controlli di scadenza diventano inaffidabili.
Quando usare ciascuno#
La mia regola empirica:
Usa le sessioni quando: Hai un'applicazione monolitica, hai bisogno di revoca istantanea, stai costruendo un prodotto consumer-facing dove la sicurezza dell'account è critica, o i tuoi requisiti di auth potrebbero cambiare frequentemente.
Usa i JWT quando: Hai un'architettura microservizi dove i servizi devono verificare l'identità in modo indipendente, stai costruendo comunicazione API-to-API, o stai implementando un sistema di autenticazione di terze parti.
In pratica: La maggior parte delle applicazioni dovrebbe usare le sessioni. L'argomento "i JWT sono più scalabili" si applica solo se hai effettivamente un problema di scaling che lo storage delle sessioni non può risolvere — e Redis gestisce milioni di lookup di sessione al secondo. Ho visto troppi progetti scegliere i JWT perché suonano più moderni, per poi costruire una blocklist e un sistema di refresh token più complesso di quanto sarebbero state le sessioni.
JWT nel dettaglio#
Anche se scegli l'auth basata su sessioni, incontrerai i JWT attraverso OAuth, OIDC e integrazioni di terze parti. Capire gli internals non è negoziabile.
Anatomia di un JWT#
Un JWT ha tre parti separate da punti: header.payload.signature
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...
Header — dichiara l'algoritmo e il tipo di token:
{
"alg": "RS256",
"typ": "JWT"
}Payload — contiene i claim. I claim standard hanno nomi corti:
{
"sub": "user_123", // Subject (di chi si tratta)
"iss": "https://auth.example.com", // Issuer (chi lo ha creato)
"aud": "https://api.example.com", // Audience (chi dovrebbe accettarlo)
"iat": 1709312000, // Issued At (timestamp Unix)
"exp": 1709312900, // Expiration (timestamp Unix)
"role": "admin" // Claim personalizzato
}Signature — dimostra che il token non è stato manomesso. Creata firmando l'header e il payload codificati con una chiave segreta.
RS256 vs HS256: questo conta davvero#
HS256 (HMAC-SHA256) — simmetrico. Lo stesso segreto firma e verifica. Semplice, ma ogni servizio che deve verificare i token deve avere il segreto. Se uno di essi viene compromesso, un attaccante può forgiare i token.
RS256 (RSA-SHA256) — asimmetrico. Una chiave privata firma, una chiave pubblica verifica. Solo il server auth ha bisogno della chiave privata. Qualsiasi servizio può verificare con la chiave pubblica. Se un servizio di verifica viene compromesso, l'attaccante può leggere i token ma non forgiarli.
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
// RS256 — usa questo quando più servizi verificano i token
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"], // CRITICO: limita sempre gli algoritmi
});
return payload;
}Regola: Usa RS256 ogni volta che i token attraversano confini di servizio. Usa HS256 solo quando lo stesso servizio firma e verifica.
L'attacco alg: none#
Questa è la vulnerabilità JWT più famosa, ed è imbarazzantemente semplice. Alcune librerie JWT facevano:
- Leggevano il campo
algdall'header - Usavano qualsiasi algoritmo specificato
- Se
alg: "none", saltavano completamente la verifica della firma
Un attaccante poteva prendere un JWT valido, cambiare il payload (es. impostare "role": "admin"), impostare alg a "none", rimuovere la firma e inviarlo. Il server lo accettava.
// VULNERABILE — non fare mai questo
function verifyJwt(token: string) {
const [headerB64, payloadB64, signature] = token.split(".");
const header = JSON.parse(atob(headerB64));
if (header.alg === "none") {
// "Nessuna firma necessaria" — CATASTROFICO
return JSON.parse(atob(payloadB64));
}
// ... verifica la firma
}Il fix è semplice: specifica sempre esplicitamente l'algoritmo atteso. Non lasciare mai che il token ti dica come verificarlo.
// SICURO — l'algoritmo è hardcoded, non letto dal token
const { payload } = await jwtVerify(token, key, {
algorithms: ["RS256"], // Accetta solo RS256 — ignora l'header
});Le librerie moderne come jose gestiscono questo correttamente di default, ma dovresti comunque passare esplicitamente l'opzione algorithms come difesa in profondità.
Attacco di confusione algoritmica#
Correlato al precedente: se un server è configurato per accettare RS256, un attaccante potrebbe:
- Ottenere la chiave pubblica del server (è pubblica, dopotutto)
- Creare un token con
alg: "HS256" - Firmarlo usando la chiave pubblica come segreto HMAC
Se il server legge l'header alg e passa alla verifica HS256, la chiave pubblica (che tutti conoscono) diventa il segreto condiviso. La firma è valida. L'attaccante ha forgiato un token.
Di nuovo, il fix è lo stesso: non fidarti mai dell'algoritmo dall'header del token. Hardcoda sempre l'algoritmo.
Rotazione dei refresh token#
Se usi JWT, hai bisogno di una strategia per i refresh token. Inviare un access token a lunga durata è cercare guai — se viene rubato, l'attaccante ha accesso per l'intera durata di vita.
Il pattern:
- Access token: di breve durata (15 minuti). Usato per le richieste API.
- Refresh token: di lunga durata (30 giorni). Usato solo per ottenere un nuovo access token.
import { randomBytes } from "crypto";
interface RefreshTokenRecord {
tokenHash: string;
userId: string;
familyId: string; // Raggruppa token correlati insieme
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);
// Memorizza il record del refresh token
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 };
}Rotazione a ogni utilizzo#
Ogni volta che il client usa un refresh token per ottenere un nuovo access token, emetti un nuovo refresh token e invalida il vecchio:
async function rotateTokens(incomingRefreshToken: string) {
const tokenHash = await hashToken(incomingRefreshToken);
const record = await db.refreshToken.findUnique({
where: { tokenHash },
});
if (!record) {
// Il token non esiste — possibile furto
return null;
}
if (record.expiresAt < new Date()) {
// Token scaduto
await db.refreshToken.delete({ where: { tokenHash } });
return null;
}
if (record.used) {
// QUESTO TOKEN ERA GIÀ STATO USATO.
// Qualcuno lo sta riproducendo — o l'utente legittimo
// o un attaccante. In ogni caso, elimina l'intera famiglia.
await db.refreshToken.deleteMany({
where: { familyId: record.familyId },
});
console.error(
`Riutilizzo refresh token rilevato per l'utente ${record.userId}, famiglia ${record.familyId}. Tutti i token della famiglia invalidati.`
);
return null;
}
// Segna il token corrente come usato (non cancellare — serve per il rilevamento del riutilizzo)
await db.refreshToken.update({
where: { tokenHash },
data: { used: true },
});
// Emetti una nuova coppia con lo stesso 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, // Stessa famiglia
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 };
}Perché l'invalidazione della famiglia conta#
Considera questo scenario:
- L'utente effettua il login, ottiene il refresh token A
- L'attaccante ruba il refresh token A
- L'attaccante usa A per ottenere una nuova coppia (access token + refresh token B)
- L'utente prova a usare A (che ha ancora) per fare il refresh
Senza rilevamento del riutilizzo, l'utente riceve semplicemente un errore. L'attaccante continua con il token B. L'utente effettua nuovamente il login, senza mai sapere che il suo account è stato compromesso.
Con rilevamento del riutilizzo e invalidazione della famiglia: quando l'utente prova a usare il token A già utilizzato, il sistema rileva il riutilizzo, invalida ogni token nella famiglia (incluso B), e forza sia l'utente che l'attaccante a ri-autenticarsi. L'utente riceve un messaggio "per favore accedi di nuovo" e potrebbe rendersi conto che qualcosa non va.
Questo è l'approccio usato da Auth0, Okta e Auth.js. Non è perfetto — se l'attaccante usa il token prima dell'utente legittimo, l'utente legittimo diventa quello che attiva l'allarme di riutilizzo. Ma è il meglio che possiamo fare con i bearer token.
OAuth 2.0 e OIDC#
OAuth 2.0 e OpenID Connect sono i protocolli dietro "Accedi con Google/GitHub/Apple." Comprenderli è essenziale anche se usi una libreria, perché quando le cose si rompono — e si romperanno — devi sapere cosa succede a livello di protocollo.
La distinzione chiave#
OAuth 2.0 è un protocollo di autorizzazione. Risponde a: "Questa applicazione può accedere ai dati di questo utente?" Il risultato è un access token che concede permessi specifici (scope).
OpenID Connect (OIDC) è un livello di autenticazione costruito sopra OAuth 2.0. Risponde a: "Chi è questo utente?" Il risultato è un ID token (un JWT) che contiene informazioni sull'identità dell'utente.
Quando fai "Accedi con Google", stai usando OIDC. Google dice alla tua app chi è l'utente (autenticazione). Potresti anche richiedere scope OAuth per accedere al loro calendario o drive (autorizzazione).
Authorization Code Flow con PKCE#
Questo è il flusso che dovresti usare per le applicazioni web. PKCE (Proof Key for Code Exchange) è stato originariamente progettato per le app mobile ma ora è raccomandato per tutti i client, incluse le applicazioni server-side.
import { randomBytes, createHash } from "crypto";
// Passo 1: Genera i valori PKCE e reindirizza l'utente
function initiateOAuthFlow() {
// Code verifier: stringa casuale di 43-128 caratteri
const codeVerifier = randomBytes(32)
.toString("base64url")
.slice(0, 43);
// Code challenge: hash SHA256 del verifier, codificato in base64url
const codeChallenge = createHash("sha256")
.update(codeVerifier)
.digest("base64url");
// State: valore casuale per la protezione CSRF
const state = randomBytes(16).toString("hex");
// Salva entrambi nella sessione (server-side!) prima del redirect
// NON mettere MAI il code_verifier in un cookie o parametro URL
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();
}// Passo 2: Gestisci il 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");
// Controlla gli errori dal provider
if (error) {
throw new Error(`Errore OAuth: ${error}`);
}
// Verifica che lo state corrisponda (protezione CSRF)
if (state !== session.oauthState) {
throw new Error("State non corrispondente — possibile attacco CSRF");
}
// Scambia l'authorization code per i token
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: dimostra che abbiamo iniziato questo flusso
}),
});
const tokens = await tokenResponse.json();
// tokens.access_token — per chiamate API a Google
// tokens.id_token — JWT con l'identità dell'utente (OIDC)
// tokens.refresh_token — per ottenere nuovi access token
// Passo 3: Verifica l'ID token e estrai le info utente
const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
return {
googleId: idTokenPayload.sub,
email: idTokenPayload.email,
name: idTokenPayload.name,
picture: idTokenPayload.picture,
};
}I tre endpoint#
Ogni provider OAuth/OIDC espone questi:
- Authorization endpoint — dove reindirizzare l'utente per il login e la concessione dei permessi. Restituisce un authorization code.
- Token endpoint — dove il tuo server scambia l'authorization code per access/refresh/ID token. Questa è una chiamata server-to-server.
- UserInfo endpoint — dove puoi recuperare dati aggiuntivi sul profilo utente usando l'access token. Con OIDC, gran parte di queste info è già nell'ID token.
Il parametro State#
Il parametro state previene gli attacchi CSRF sul callback OAuth. Senza di esso:
- L'attaccante avvia un flusso OAuth sulla propria macchina, ottiene un authorization code
- L'attaccante crea un URL:
https://tuaapp.com/callback?code=CODICE_ATTACCANTE - L'attaccante inganna una vittima facendola cliccare (link email, immagine nascosta)
- La tua app scambia il codice dell'attaccante e collega l'account Google dell'attaccante alla sessione della vittima
Con state: la tua app genera un valore casuale, lo memorizza nella sessione, e lo include nell'URL di autorizzazione. Quando arriva il callback, verifichi che lo state corrisponda. L'attaccante non può forgiarlo perché non ha accesso alla sessione della vittima.
Auth.js (NextAuth) con Next.js App Router#
Auth.js è ciò a cui mi rivolgo per primo nella maggior parte dei progetti Next.js. Gestisce il flusso OAuth, la gestione delle sessioni, la persistenza nel database e la protezione CSRF. Ecco un setup production-ready.
Configurazione base#
// 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),
// Usa sessioni database (non JWT) per maggiore sicurezza
session: {
strategy: "database",
maxAge: 30 * 24 * 60 * 60, // 30 giorni
updateAge: 24 * 60 * 60, // Estendi la sessione ogni 24 ore
},
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
// Richiedi scope specifici
authorization: {
params: {
scope: "openid email profile",
prompt: "consent",
access_type: "offline", // Ottieni il refresh token
},
},
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
// Login email/password (usa con cautela)
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: {
// Controlla chi può effettuare il login
async signIn({ user, account }) {
// Blocca il login per gli utenti bannati
if (user.id) {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { banned: true },
});
if (dbUser?.banned) return false;
}
return true;
},
// Aggiungi campi personalizzati alla sessione
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
// Recupera il ruolo dal 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;Protezione 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");
// Reindirizza gli utenti loggati lontano dalle pagine auth
if (isLoggedIn && isAuthPage) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
// Reindirizza gli utenti non autenticati al login
if (!isLoggedIn && isProtectedRoute) {
const callbackUrl = encodeURIComponent(req.nextUrl.pathname);
return NextResponse.redirect(
new URL(`/login?callbackUrl=${callbackUrl}`, req.nextUrl)
);
}
// Controlla il ruolo admin
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",
],
};Usare la sessione nei Server Component#
// 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>Benvenuto, {session.user.name}</h1>
<p>Ruolo: {session.user.role}</p>
</div>
);
}Usare la sessione nei Client Component#
"use client";
import { useSession } from "next-auth/react";
export function UserMenu() {
const { data: session, status } = useSession();
if (status === "loading") {
return <div>Caricamento...</div>;
}
if (status === "unauthenticated") {
return <a href="/login">Accedi</a>;
}
return (
<div>
<img
src={session?.user?.image ?? "/default-avatar.png"}
alt={session?.user?.name ?? "Utente"}
/>
<span>{session?.user?.name}</span>
</div>
);
}Passkey (WebAuthn)#
Le passkey sono il miglioramento più significativo dell'autenticazione degli ultimi anni. Sono resistenti al phishing, resistenti al replay, e eliminano l'intera categoria di vulnerabilità legate alle password. Se stai iniziando un nuovo progetto nel 2026, dovresti supportare le passkey.
Come funzionano le passkey#
Le passkey usano crittografia a chiave pubblica, supportata da biometria o PIN del dispositivo:
- Registrazione: Il browser genera una coppia di chiavi. La chiave privata resta sul dispositivo (in un secure enclave, protetta dalla biometria). La chiave pubblica viene inviata al tuo server.
- Autenticazione: Il server invia una challenge (byte casuali). Il dispositivo firma la challenge con la chiave privata (dopo la verifica biometrica). Il server verifica la firma con la chiave pubblica memorizzata.
Nessun segreto condiviso attraversa mai la rete. Non c'è niente da phishare, niente da esporre, niente da provare in massa.
Perché le passkey sono resistenti al phishing#
Quando una passkey viene creata, è legata all'origin (es. https://example.com). Il browser userà la passkey solo sull'esatto origin per cui è stata creata. Se un attaccante crea un sito simile su https://exarnple.com, la passkey semplicemente non verrà offerta. Questo è imposto dal browser, non dalla vigilanza dell'utente.
Questo è fondamentalmente diverso dalle password, dove gli utenti inseriscono regolarmente le proprie credenziali su siti di phishing perché la pagina sembra giusta.
Implementazione con SimpleWebAuthn#
SimpleWebAuthn è la libreria che raccomando. Gestisce correttamente il protocollo WebAuthn e ha buoni tipi TypeScript.
// Server-side: Registrazione
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) {
// Ottieni le passkey esistenti dell'utente per escluderle
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", // Non serve attestation per la maggior parte delle app
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
};
const registrationOptions = await generateRegistrationOptions(options);
// Memorizza la challenge temporaneamente — ci serve per la verifica
await redis.set(
`webauthn:challenge:${userId}`,
registrationOptions.challenge,
"EX",
300 // scadenza 5 minuti
);
return registrationOptions;
}
async function finishRegistration(userId: string, response: unknown) {
const expectedChallenge = await redis.get(`webauthn:challenge:${userId}`);
if (!expectedChallenge) {
throw new Error("Challenge scaduta o non trovata");
}
let verification: VerifiedRegistrationResponse;
try {
verification = await verifyRegistrationResponse({
response: response as any,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
} catch (error) {
throw new Error(`Verifica registrazione fallita: ${error}`);
}
if (!verification.verified || !verification.registrationInfo) {
throw new Error("Verifica registrazione fallita");
}
const { credential } = verification.registrationInfo;
// Memorizza la credenziale nel database
await db.credential.create({
data: {
userId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: credential.transports ?? [],
},
});
// Pulizia
await redis.del(`webauthn:challenge:${userId}`);
return { verified: true };
}// Server-side: Autenticazione
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
async function startAuthentication(userId?: string) {
let allowCredentials;
// Se conosciamo l'utente (es. ha inserito la email), limitiamo alle sue passkey
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",
});
// Memorizza la challenge per la verifica
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("Credenziale non trovata");
}
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("Verifica autenticazione fallita");
}
// IMPORTANTE: Aggiorna il contatore per prevenire attacchi replay
await db.credential.update({
where: { credentialId: response.id },
data: {
counter: verification.authenticationInfo.newCounter,
},
});
return { verified: true, userId: credential.userId };
}// Client-side: Registrazione
import { startRegistration as webAuthnRegister } from "@simplewebauthn/browser";
async function registerPasskey() {
// Ottieni le opzioni dal tuo server
const optionsResponse = await fetch("/api/auth/webauthn/register", {
method: "POST",
});
const options = await optionsResponse.json();
try {
// Questo attiva l'interfaccia passkey del browser (prompt biometrico)
const credential = await webAuthnRegister(options);
// Invia la credenziale al tuo server per la verifica
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 registrata con successo!");
}
} catch (error) {
if ((error as Error).name === "NotAllowedError") {
console.log("L'utente ha annullato la registrazione della passkey");
}
}
}Attestation vs Assertion#
Due termini che incontrerai:
- Attestation (registrazione): Il processo di creazione di una nuova credenziale. L'autenticatore "attesta" la propria identità e capacità. Per la maggior parte delle applicazioni, non serve verificare l'attestation — imposta
attestationType: "none". - Assertion (autenticazione): Il processo di utilizzo di una credenziale esistente per firmare una challenge. L'autenticatore "asserisce" che l'utente è chi dice di essere.
Implementazione MFA#
Anche con le passkey, incontrerai scenari dove è necessario MFA via TOTP — passkey come secondo fattore insieme alle password, o supporto per utenti i cui dispositivi non supportano le passkey.
TOTP (Time-Based One-Time Passwords)#
TOTP è il protocollo dietro Google Authenticator, Authy e 1Password. Funziona così:
- Il server genera un segreto casuale (codificato in base32)
- L'utente scansiona un QR code contenente il segreto
- Sia il server che l'app authenticator calcolano lo stesso codice a 6 cifre dal segreto e dall'ora corrente
- I codici cambiano ogni 30 secondi
import { createHmac, randomBytes } from "crypto";
// Genera un segreto TOTP per un utente
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;
}
// Genera l'URI TOTP per il 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`;
}// Verifica un codice TOTP
function verifyTOTP(secret: string, code: string, window: number = 1): boolean {
const secretBuffer = base32Decode(secret);
const now = Math.floor(Date.now() / 1000);
// Controlla lo step temporale corrente e quelli adiacenti (tolleranza clock drift)
for (let i = -window; i <= window; i++) {
const timeStep = Math.floor(now / 30) + i;
const expectedCode = generateTOTPCode(secretBuffer, timeStep);
// Confronto a tempo costante per prevenire attacchi timing
if (timingSafeEqual(code, expectedCode)) {
return true;
}
}
return false;
}
function generateTOTPCode(secret: Buffer, timeStep: number): string {
// Converti lo step temporale in un buffer big-endian di 8 byte
const timeBuffer = Buffer.alloc(8);
timeBuffer.writeBigInt64BE(BigInt(timeStep));
// HMAC-SHA1
const hmac = createHmac("sha1", secret).update(timeBuffer).digest();
// Troncatura dinamica
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());
}Codici di backup#
Gli utenti perdono i telefoni. Genera sempre codici di backup durante il setup MFA:
import { randomBytes, createHash } from "crypto";
function generateBackupCodes(count: number = 10): string[] {
return Array.from({ length: count }, () =>
randomBytes(4).toString("hex").toUpperCase() // codici hex di 8 caratteri
);
}
async function storeBackupCodes(userId: string, codes: string[]) {
// Hasha i codici prima di memorizzarli — trattali come password
const hashedCodes = codes.map((code) =>
createHash("sha256").update(code).digest("hex")
);
await db.backupCode.createMany({
data: hashedCodes.map((hash) => ({
userId,
codeHash: hash,
used: false,
})),
});
// Restituisci i codici in chiaro UNA SOLA VOLTA perché l'utente li salvi
// Dopo questo, abbiamo solo gli hash
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;
// Segna come usato — ogni codice di backup funziona esattamente una volta
await db.backupCode.update({
where: { id: backupCode.id },
data: { used: true, usedAt: new Date() },
});
return true;
}Flusso di recupero#
Il recupero MFA è la parte che la maggior parte dei tutorial salta e la maggior parte delle applicazioni reali sbaglia. Ecco cosa implemento:
- Primario: Codice TOTP dall'app authenticator
- Secondario: Uno dei 10 codici di backup
- Ultima risorsa: Recupero via email con un periodo di attesa di 24 ore e notifica sugli altri canali verificati dell'utente
Il periodo di attesa è critico. Se un attaccante ha compromesso l'email dell'utente, non vuoi permettergli di disabilitare l'MFA istantaneamente. Il ritardo di 24 ore dà all'utente legittimo il tempo di notare l'email e intervenire.
async function initiateAccountRecovery(email: string) {
const user = await db.user.findUnique({ where: { email } });
if (!user) {
// Non rivelare se l'account esiste
return { message: "Se quell'email esiste, abbiamo inviato le istruzioni di recupero." };
}
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 ore
status: "pending",
},
});
// Invia email con link di recupero
await sendEmail(email, {
subject: "Richiesta di recupero account",
body: `
È stata fatta una richiesta per disabilitare l'MFA sul tuo account.
Se sei stato tu, clicca il link qui sotto dopo 24 ore: ...
Se NON sei stato tu, cambia immediatamente la tua password.
`,
});
return { message: "Se quell'email esiste, abbiamo inviato le istruzioni di recupero." };
}Pattern di autorizzazione#
L'autenticazione ti dice chi è qualcuno. L'autorizzazione ti dice cosa gli è permesso fare. Sbagliare questo è il modo in cui finisci sui giornali.
RBAC vs ABAC#
RBAC (Role-Based Access Control): Gli utenti hanno ruoli, i ruoli hanno permessi. Semplice, facile da ragionare, funziona per la maggior parte delle applicazioni.
// RBAC — controlli di ruolo diretti
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: ["*"], // Attento con i wildcard
};
function hasPermission(role: Role, permission: string): boolean {
const permissions = ROLE_PERMISSIONS[role];
return permissions.includes("*") || permissions.includes(permission);
}
// Utilizzo in una route API
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return Response.json({ error: "Non autorizzato" }, { status: 401 });
}
if (!hasPermission(session.user.role as Role, "delete:posts")) {
return Response.json({ error: "Vietato" }, { status: 403 });
}
const { id } = await params;
await db.post.delete({ where: { id } });
return Response.json({ success: true });
}ABAC (Attribute-Based Access Control): I permessi dipendono dagli attributi dell'utente, della risorsa e del contesto. Più flessibile ma più complesso.
// ABAC — quando RBAC non basta
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;
// Gli utenti possono sempre leggere le proprie risorse
if (action === "read" && resource.ownerId === user.id) {
return true;
}
// Gli admin possono leggere qualsiasi risorsa nel loro dipartimento
if (
action === "read" &&
user.role === "admin" &&
user.department === resource.department
) {
return true;
}
// Le risorse classificate richiedono MFA e un livello minimo di clearance
if (resource.classification === "confidential") {
if (!environment.mfaVerified) return false;
if (user.clearanceLevel < 3) return false;
}
// Azioni distruttive bloccate fuori dall'orario lavorativo
if (action === "delete") {
const hour = environment.time.getHours();
if (hour < 9 || hour > 17) return false;
}
return false; // Nega per default
}La regola "Controlla al confine"#
Questo è il principio di autorizzazione più importante in assoluto: controlla i permessi ad ogni confine di fiducia, non solo a livello di UI.
// SBAGLIATO — controlla solo nel componente
function DeleteButton({ post }: { post: Post }) {
const { data: session } = useSession();
// Questo nasconde il pulsante, ma non impedisce la cancellazione
if (session?.user?.role !== "admin") return null;
return <button onClick={() => deletePost(post.id)}>Elimina</button>;
}
// ANCHE SBAGLIATO — controlla in una server action ma non nella route API
async function deletePostAction(postId: string) {
const session = await auth();
if (session?.user?.role !== "admin") throw new Error("Vietato");
await db.post.delete({ where: { id: postId } });
}
// Un attaccante può comunque chiamare POST /api/posts/123 direttamente
// CORRETTO — controlla ad ogni confine
// 1. Nascondi il pulsante nella UI (UX, non sicurezza)
// 2. Controlla nella server action (difesa in profondità)
// 3. Controlla nella route API (il vero confine di sicurezza)
// 4. Opzionalmente, controlla nel middleware (per protezione a livello di route)Il controllo UI è per l'esperienza utente. Il controllo server è per la sicurezza. Non affidarti mai a uno solo di essi.
Controlli dei permessi nel middleware di Next.js#
Il middleware viene eseguito prima di ogni richiesta corrispondente. È un buon posto per il controllo d'accesso a grana grossa:
// "Questo utente è autorizzato ad accedere a questa sezione?"
// I controlli a grana fine ("Questo utente può modificare QUESTO post?") appartengono al route handler
// perché il middleware non ha facile accesso al body della richiesta o ai parametri della route.
export default auth((req) => {
const path = req.nextUrl.pathname;
const role = req.auth?.user?.role;
// Controllo d'accesso a livello di route
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();
});Vulnerabilità comuni#
Questi sono gli attacchi che vedo più spesso nelle codebase reali. Comprenderli è essenziale.
Session Fixation#
L'attacco: Un attaccante crea una sessione valida sul tuo sito, poi inganna una vittima facendole usare quel session ID (es. tramite un parametro URL o impostando un cookie attraverso un sottodominio). Quando la vittima effettua il login, la sessione dell'attaccante ha ora un utente autenticato.
Il fix: Rigenera sempre il session ID dopo un'autenticazione riuscita. Non lasciare mai che un session ID pre-autenticazione venga portato in una sessione post-autenticazione.
async function login(credentials: { email: string; password: string }, request: Request) {
const user = await verifyCredentials(credentials);
if (!user) throw new Error("Credenziali non valide");
// CRITICO: Elimina la vecchia sessione e creane una nuova
const oldSessionId = getSessionIdFromCookie(request);
if (oldSessionId) {
await redis.del(`session:${oldSessionId}`);
}
// Crea una sessione completamente nuova con un nuovo ID
const newSessionId = await createSession(user.id, request);
return newSessionId;
}CSRF (Cross-Site Request Forgery)#
L'attacco: Un utente è loggato sul tuo sito. Visita una pagina malevola che fa una richiesta al tuo sito. Poiché i cookie vengono inviati automaticamente, la richiesta è autenticata.
Il fix moderno: Cookie SameSite. Impostare SameSite: Lax (il default nella maggior parte dei browser ora) impedisce l'invio dei cookie nelle richieste POST cross-origin, il che copre la maggior parte degli scenari CSRF.
// SameSite=Lax copre la maggior parte degli scenari CSRF:
// - Blocca i cookie su POST, PUT, DELETE cross-origin
// - Permette i cookie su GET cross-origin (navigazione top-level)
// Questo va bene perché le richieste GET non dovrebbero avere effetti collaterali
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax", // Questa è la tua protezione CSRF
maxAge: 86400,
path: "/",
});Per le API che accettano JSON, ottieni protezione aggiuntiva gratis: l'header Content-Type: application/json non può essere impostato dai form HTML, e CORS impedisce a JavaScript su altri origin di fare richieste con header personalizzati.
Se hai bisogno di garanzie più forti (es. accetti invii di form), usa il pattern double-submit cookie o un synchronizer token. Auth.js gestisce questo per te.
Open Redirect in OAuth#
L'attacco: Un attaccante crea un URL di callback OAuth che reindirizza al proprio sito dopo l'autenticazione: https://tuaapp.com/callback?redirect_to=https://evil.com/ruba-token
Se il tuo gestore di callback reindirizza ciecamente al parametro redirect_to, l'utente finisce sul sito dell'attaccante, potenzialmente con i token nell'URL.
// VULNERABILE
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// ... autentica l'utente ...
return Response.redirect(redirectTo); // Potrebbe essere https://evil.com!
}
// SICURO
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// Valida l'URL di redirect
const safeRedirect = sanitizeRedirectUrl(redirectTo, request.url);
// ... autentica l'utente ...
return Response.redirect(safeRedirect);
}
function sanitizeRedirectUrl(redirect: string, baseUrl: string): string {
try {
const url = new URL(redirect, baseUrl);
const base = new URL(baseUrl);
// Permetti solo redirect allo stesso origin
if (url.origin !== base.origin) {
return "/";
}
// Permetti solo redirect di percorso (niente javascript: o data: URI)
if (!url.pathname.startsWith("/")) {
return "/";
}
return url.pathname + url.search;
} catch {
return "/";
}
}Perdita di token via Referrer#
Se metti i token negli URL (non farlo), verranno esposti attraverso l'header Referer quando gli utenti cliccano sui link. Questo ha causato vere violazioni, inclusa una su GitHub.
Regole:
- Non mettere mai token nei parametri URL di query per l'autenticazione
- Imposta
Referrer-Policy: strict-origin-when-cross-origin(o più restrittivo) - Se devi mettere token negli URL (es. link di verifica email), rendili monouso e a breve scadenza
// Nel tuo middleware o layout Next.js
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");JWT Key Injection#
Un attacco meno conosciuto: alcune librerie JWT supportano un header jwk o jku che dice al verificatore dove trovare la chiave pubblica. Un attaccante può:
- Generare la propria coppia di chiavi
- Creare un JWT con il proprio payload e firmarlo con la propria chiave privata
- Impostare l'header
jwkper puntare alla propria chiave pubblica
Se la tua libreria recupera e usa ciecamente la chiave dall'header jwk, la firma è verificata. Il fix: non permettere mai al token di specificare la propria chiave di verifica. Usa sempre chiavi dalla tua configurazione.
Il mio stack auth nel 2026#
Dopo anni di costruzione di sistemi di autenticazione, ecco cosa uso effettivamente oggi.
Per la maggior parte dei progetti: Auth.js + PostgreSQL + Passkey#
Questo è il mio stack default per i nuovi progetti:
- Auth.js (v5) per il lavoro pesante: provider OAuth, gestione sessioni, CSRF, adapter database
- PostgreSQL con adapter Prisma per lo storage di sessioni e account
- Passkey via SimpleWebAuthn come metodo di login primario per i nuovi utenti
- Email/password come fallback per gli utenti che non possono usare le passkey
- TOTP MFA come secondo fattore per i login basati su password
La strategia di sessione è backed da database (non JWT), il che mi dà revoca istantanea e gestione semplice delle sessioni.
// Questo è il mio tipico auth.ts per un nuovo progetto
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 ha supporto passkey integrato
// Usa SimpleWebAuthn sotto il cofano
}),
],
experimental: {
enableWebAuthn: true,
},
});Quando usare Clerk o Auth0 al suo posto#
Mi rivolgo a un provider auth gestito quando:
- Il progetto ha bisogno di SSO aziendale (SAML, SCIM). Implementare SAML correttamente è un progetto di più mesi. Clerk lo fa out of the box.
- Il team non ha competenze di sicurezza. Se nessuno nel team sa spiegare PKCE, non dovrebbero costruire l'auth da zero.
- Il time to market conta più del costo. Auth.js è gratuito ma richiede giorni per essere configurato correttamente. Clerk richiede un pomeriggio.
- Hai bisogno di garanzie di compliance (SOC 2, HIPAA). I provider gestiti gestiscono la certificazione di conformità.
I compromessi dei provider gestiti:
- Costo: Clerk addebita per utente attivo mensile. Su larga scala, si accumula.
- Vendor lock-in: Migrare via da Clerk o Auth0 è doloroso. La tua tabella utenti è sui loro server.
- Limiti di personalizzazione: Se il tuo flusso auth è insolito, combatterai le opinioni del provider.
- Latenza: Ogni controllo auth va a un'API di terze parti. Con sessioni database, è una query locale.
Cosa evito#
- Creare la mia crittografia. Uso
joseper i JWT,@simplewebauthn/serverper le passkey,bcryptoargon2per le password. Mai fatto a mano. - Memorizzare password in SHA256. Usa bcrypt (cost factor 12+) o argon2id. SHA256 è troppo veloce — un attaccante può provare miliardi di hash al secondo con una GPU.
- Access token a lunga durata. 15 minuti massimo. Usa la rotazione dei refresh token per sessioni più lunghe.
- Segreti simmetrici per la verifica cross-service. Se più servizi devono verificare i token, usa RS256 con una coppia di chiavi pubblica/privata.
- Session ID personalizzati con entropia insufficiente. Usa
crypto.randomBytes(32)come minimo. UUID v4 è accettabile ma ha meno entropia dei byte casuali grezzi.
Hashing delle password: il modo corretto#
Dato che ne abbiamo parlato — ecco come hashare le password correttamente nel 2026:
import { hash, verify } from "@node-rs/argon2";
// Argon2id è l'algoritmo raccomandato
// Questi sono valori predefiniti ragionevoli per un'applicazione web
async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterazioni
parallelism: 4, // 4 thread
});
}
async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
try {
return await verify(hashedPassword, password);
} catch {
return false;
}
}Perché argon2id rispetto a bcrypt? Argon2id è memory-hard, il che significa che attaccarlo richiede non solo potenza CPU ma anche grandi quantità di RAM. Questo rende gli attacchi con GPU e ASIC significativamente più costosi. Bcrypt va ancora bene — non è rotto — ma argon2id è la scelta migliore per i nuovi progetti.
Checklist di sicurezza#
Prima di mettere in produzione qualsiasi sistema di autenticazione, verifica:
- Le password sono hashate con argon2id o bcrypt (cost 12+)
- Le sessioni vengono rigenerate dopo il login (previene la session fixation)
- I cookie sono
HttpOnly,Secure,SameSite=LaxoStrict - I JWT specificano gli algoritmi esplicitamente (non fidarti mai dell'header
alg) - Gli access token scadono in 15 minuti o meno
- La rotazione dei refresh token è implementata con rilevamento del riutilizzo
- Il parametro state di OAuth è verificato (protezione CSRF)
- Gli URL di redirect sono validati contro una allowlist
- Il rate limiting è applicato agli endpoint di login, registrazione e reset password
- I tentativi di login falliti sono loggati con l'IP ma non con le password
- Il blocco dell'account dopo N tentativi falliti (con ritardi progressivi, non blocco permanente)
- I token di reset password sono monouso e scadono in 1 ora
- I codici di backup MFA sono hashati come le password
- CORS è configurato per permettere solo origin conosciuti
- L'header
Referrer-Policyè impostato - Nessun dato sensibile nei payload JWT (sono leggibili da chiunque)
- Il contatore WebAuthn è verificato e aggiornato (previene la clonazione delle credenziali)
Questa lista non è esaustiva, ma copre le vulnerabilità che ho visto più spesso nei sistemi in produzione.
Conclusioni#
L'autenticazione è uno di quei domini dove il panorama continua ad evolversi, ma i fondamentali restano gli stessi: verifica l'identità, emetti le credenziali minime necessarie, controlla i permessi ad ogni confine, e presupponi la violazione.
Il più grande cambiamento nel 2026 è il mainstream delle passkey. Il supporto dei browser è universale, il supporto delle piattaforme (iCloud Keychain, Google Password Manager) rende l'UX fluida, e le proprietà di sicurezza sono genuinamente superiori a qualsiasi cosa abbiamo avuto prima. Se stai costruendo una nuova applicazione, rendi le passkey il tuo metodo di login primario e tratta le password come fallback.
Il secondo più grande cambiamento è che costruire la propria auth è diventato più difficile da giustificare. Auth.js v5, Clerk e soluzioni simili gestiscono le parti difficili correttamente. L'unica ragione per andare su misura è quando i tuoi requisiti genuinamente non si adattano a nessuna soluzione esistente — e questo è più raro di quanto la maggior parte degli sviluppatori pensi.
Qualunque cosa tu scelga, testa la tua auth come farebbe un attaccante. Prova a riprodurre token, forgiare firme, accedere a route che non dovresti, e manipolare URL di redirect. I bug che trovi prima del lancio sono quelli che non finiscono sui giornali.