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.
Questa è la prima decisione che affronterai, e internet è pieno di cattivi consigli al riguardo. Lascia che ti esponga cosa conta davvero.
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:
Gli svantaggi sono altrettanto reali:
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:
Gli svantaggi — e questi sono quelli che la gente sorvola:
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.
Anche se scegli l'auth basata su sessioni, incontrerai i JWT attraverso OAuth, OIDC e integrazioni di terze parti. Capire gli internals non è negoziabile.
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.
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.
alg: none#Questa è la vulnerabilità JWT più famosa, ed è imbarazzantemente semplice. Alcune librerie JWT facevano:
alg dall'headeralg: "none", saltavano completamente la verifica della firmaUn 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à.
Correlato al precedente: se un server è configurato per accettare RS256, un attaccante potrebbe:
alg: "HS256"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.
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:
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 };
}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 };
}Considera questo scenario:
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 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.
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).
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,
};
}Ogni provider OAuth/OIDC espone questi:
Il parametro state previene gli attacchi CSRF sul callback OAuth. Senza di esso:
https://tuaapp.com/callback?code=CODICE_ATTACCANTECon 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 è 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.
// 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",
},
});// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;// src/middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAuthPage = req.nextUrl.pathname.startsWith("/login") || req.nextUrl.pathname.startsWith("/register");
const isProtectedRoute =
req.nextUrl.pathname.startsWith("/dashboard") ||
req.nextUrl.pathname.startsWith("/settings") ||
req.nextUrl.pathname.startsWith("/admin");
const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");
// 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"],
};// 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>
);
}"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>
);
}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.
Le passkey usano crittografia a chiave pubblica, supportata da biometria o PIN del dispositivo:
Nessun segreto condiviso attraversa mai la rete. Non c'è niente da phishare, niente da esporre, niente da provare in massa.
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.
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");
}
}
}Due termini che incontrerai:
attestationType: "none".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 è il protocollo dietro Google Authenticator, Authy e 1Password. Funziona così:
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());
}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;
}Il recupero MFA è la parte che la maggior parte dei tutorial salta e la maggior parte delle applicazioni reali sbaglia. Ecco cosa implemento:
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." };
}L'autenticazione ti dice chi è qualcuno. L'autorizzazione ti dice cosa gli è permesso fare. Sbagliare questo è il modo in cui finisci sui giornali.
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
}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.
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();
});Questi sono gli attacchi che vedo più spesso nelle codebase reali. Comprenderli è essenziale.
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;
}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.
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 "/";
}
}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:
Referrer-Policy: strict-origin-when-cross-origin (o più restrittivo)// Nel tuo middleware o layout Next.js
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");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ò:
jwk per puntare alla propria chiave pubblicaSe 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.
Dopo anni di costruzione di sistemi di autenticazione, ecco cosa uso effettivamente oggi.
Questo è il mio stack default per i nuovi progetti:
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,
},
});Mi rivolgo a un provider auth gestito quando:
I compromessi dei provider gestiti:
jose per i JWT, @simplewebauthn/server per le passkey, bcrypt o argon2 per le password. Mai fatto a mano.crypto.randomBytes(32) come minimo. UUID v4 è accettabile ma ha meno entropia dei byte casuali grezzi.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.
Prima di mettere in produzione qualsiasi sistema di autenticazione, verifica:
HttpOnly, Secure, SameSite=Lax o Strictalg)Referrer-Policy è impostatoQuesta lista non è esaustiva, ma copre le vulnerabilità che ho visto più spesso nei sistemi in produzione.
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.