Ogni volta che un utente clicca su “Accedi con Google” o “Accedi con GitHub”, entra in azione OAuth 2.0. Il protocollo gestisce miliardi di autenticazioni ogni giorno, ma implementarlo correttamente in Node.js richiede più di una semplice installazione di passport. Bisogna capire PKCE, la rotazione dei refresh token, il pinning degli algoritmi e la corretta gestione dei cookie. Questa guida copre tutti e 12 gli step in 30 minuti.
Cos’è OAuth 2.0 e Perché Usarlo
OAuth 2.0 (RFC 6749) è un framework di autorizzazione che permette a un’applicazione di ottenere accesso limitato a un account utente su un servizio di terze parti, senza che l’utente condivida le proprie credenziali. OpenID Connect (OIDC) è uno strato di identità costruito sopra OAuth 2.0: aggiunge l’autenticazione vera e propria tramite un ID token in formato JWT.
La differenza pratica: OAuth 2.0 risponde alla domanda “posso accedere a questa risorsa?”, OIDC risponde a “chi sei?”. Nelle applicazioni moderne, i due protocolli vengono usati insieme. L’authorization server (Google, GitHub, Keycloak, Auth0) emette un access token per le API e un ID token per l’identità.
Il flusso più sicuro nel 2026 è l’Authorization Code Flow con PKCE (RFC 7636). PKCE, pronunciato “pixy”, risolve il rischio di intercettazione del codice di autorizzazione nei client pubblici (SPA, app mobile), rendendo ogni richiesta crittograficamente legata a un verificatore segreto generato localmente. RFC 9700 (OAuth 2.0 Security Best Current Practice) pubblicato nel 2025 raccomanda PKCE per tutti i tipi di client, senza eccezioni.
| Flusso OAuth 2.0 | Caso d’uso | PKCE richiesto | Livello di sicurezza |
|---|---|---|---|
| Authorization Code + PKCE | Web app, SPA, mobile | Si | Alto |
| Authorization Code (senza PKCE) | Web app server-side legacy | No (sconsigliato) | Medio |
| Client Credentials | M2M, microservizi | No | Alto (per M2M) |
| Device Authorization | Smart TV, CLI | No | Medio |
| Implicit Flow | Deprecato dal 2019 | N/A | Basso (non usare) |
Prerequisiti e Versioni
Prima di iniziare, verifica di avere le versioni corrette installate. Il progetto usa Node.js LTS: la versione 22.x (LTS attivo) o 24.x (LTS corrente). A gennaio 2026 il team Node.js ha rilasciato patch di sicurezza per tutte le linee attive, risolvendo 2 vulnerabilità ad alta severità per versione, tra cui CVE-2026-21637 (HashDoS in V8 tramite collisioni hash su stringhe integer-like). Il 17 giugno 2026, data di pubblicazione di questa guida, è uscita un’ulteriore tornata di patch per 26.x, 24.x e 22.x.
node -v # >= 20.20.2 oppure >= 22.22.2 oppure >= 24.14.1
npm -v # >= 10.x
Pacchetti necessari con versioni minime:
| Pacchetto | Versione | Scopo |
|---|---|---|
| express | ^5.x | Framework HTTP |
| passport | ^0.7.x | Middleware autenticazione |
| passport-google-oauth20 | ^2.0.x | Strategy OAuth2 Google |
| express-session | ^1.18.x | Gestione sessioni lato server |
| connect-redis | ^8.x | Store sessioni Redis |
| jose | ^6.x | Verifica JWT e ID token OIDC |
| dotenv | ^16.x | Variabili d’ambiente |
| crypto (built-in) | N/A | Generazione PKCE code verifier |
Ti serve anche un account Google Cloud con un progetto configurato in Google Cloud Console e le credenziali OAuth2 (Client ID e Client Secret). La sezione Step 3 mostra la procedura esatta. Per i test locali, puoi usare anche GitHub come provider alternativo con il pacchetto passport-github2, che segue lo stesso schema di configurazione.
Panoramica del Flusso Authorization Code con PKCE
Prima di scrivere codice, capire il flusso evita molti errori di implementazione. Con PKCE, la sequenza tra browser, server Node.js e authorization server segue 6 passaggi distinti:
- Il server genera un
code_verifiercasuale (32 byte, 256 bit di entropia) e ne calcola ilcode_challengecon SHA-256. - Il server reindirizza l’utente all’authorization server inviando
code_challengeecode_challenge_method=S256. - L’utente si autentica e l’authorization server rilascia un codice di autorizzazione monouso.
- Il browser invia il codice di autorizzazione al server Node.js tramite il redirect URI.
- Il server Node.js scambia il codice con i token inviando anche il
code_verifieroriginale. - L’authorization server verifica il verifier contro il challenge e rilascia
access_token,refresh_tokene (con OIDC)id_token.
Il punto critico: il code_verifier non lascia mai il server. Se un attaccante intercetta il codice di autorizzazione al passo 3, non può scambiarlo senza il verifier. Questo neutralizza il classico attacco CSRF sull’endpoint di callback OAuth2.
Step 1: Inizializza il Progetto Node.js
Crea una cartella per il progetto e inizializza npm. Il progetto usa ES modules per avere import/export nativi, coerenti con le best practice Node.js 2026. CommonJS (require()) funziona ancora ma molti pacchetti moderni sono ora ESM-only.
mkdir oauth2-oidc-nodejs && cd oauth2-oidc-nodejs
npm init -y
# Abilita ES modules
npm pkg set type=module
Crea la struttura del progetto. Separare le route dalle utility di autenticazione facilita i test e riduce il coupling:
oauth2-oidc-nodejs/
├── src/
│ ├── app.js # Entry point Express
│ ├── auth/
│ │ ├── passport.js # Configurazione Passport e strategy
│ │ └── pkce.js # Utility PKCE per provider custom
│ ├── middleware/
│ │ └── requireAuth.js
│ └── routes/
│ ├── auth.js # /auth/google, /auth/google/callback, /auth/logout
│ └── profile.js # Route protette
├── .env # Non committare mai
├── .env.example # Template pubblico senza segreti
└── package.json
Step 2: Installa le Dipendenze
Installa tutti i pacchetti in un unico comando, poi esegui npm audit subito dopo. Nell’ecosistema Node.js del 2026, le dipendenze di terze parti rimangono la principale superficie di attacco per le supply chain. Il blog ha già documentato come nel 2025 oltre 1,2 milioni di pacchetti malevoli siano stati caricati su npm.
npm install express passport passport-google-oauth20 express-session connect-redis jose dotenv helmet
npm install --save-dev nodemon
# Verifica zero vulnerabilità critiche prima di procedere
npm audit
Se npm audit segnala vulnerabilità high o critical nelle dipendenze dirette, risolvi prima di andare avanti. Per le vulnerabilità nelle dipendenze transitive, valuta se il percorso di codice è effettivamente raggiungibile nella tua applicazione. Il comando npm audit --json | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[‘metadata’][‘vulnerabilities’])" mostra il conteggio per severità.
Step 3: Configura le Credenziali OAuth2 su Google Cloud
Vai su console.cloud.google.com, crea un progetto nuovo (o usa uno esistente), poi segui questo percorso: API e servizi > Credenziali > Crea credenziali > ID client OAuth 2.0. Seleziona “Applicazione web”. In “URI di reindirizzamento autorizzati” aggiungi i due endpoint:
http://localhost:3000/auth/google/callback # sviluppo locale
https://tuodominio.com/auth/google/callback # produzione
Scarica il JSON con Client ID e Client Secret. Crea il file .env nella root del progetto:
GOOGLE_CLIENT_ID=123456789-abcdefghijk.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxxxx
SESSION_SECRET=una_stringa_casuale_di_almeno_32_caratteri_generata_con_openssl_rand
REDIS_URL=redis://localhost:6379
BASE_URL=http://localhost:3000
NODE_ENV=development
Pitfall critica: il file .env deve essere nel .gitignore prima del primo commit. Una chiave OAuth2 esposta su GitHub viene rilevata dai bot entro minuti e porta alla revoca forzata delle credenziali da parte di Google. Aggiungi subito: echo ".env" >> .gitignore. Genera il SESSION_SECRET con openssl rand -hex 32: serve almeno 256 bit di entropia.
Step 4: Crea il Server Express con Session Store Redis
Il session store in memoria di Express funziona solo in sviluppo: perde tutte le sessioni al riavvio del server e produce memory leak in produzione. Redis risolve entrambi i problemi e scala orizzontalmente per più istanze. Il file src/app.js:
import 'dotenv/config';
import express from 'express';
import helmet from 'helmet';
import session from 'express-session';
import { createClient } from 'redis';
import { RedisStore } from 'connect-redis';
import passport from 'passport';
import './auth/passport.js';
import authRouter from './routes/auth.js';
import profileRouter from './routes/profile.js';
const app = express();
const PORT = process.env.PORT || 3000;
// Sicurezza header HTTP con Helmet
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
connectSrc: ["'self'", 'https://accounts.google.com'],
frameSrc: ["'none'"],
objectSrc: ["'none'"]
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
}
}));
// Client Redis con gestione errori
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.on('error', (err) => console.error('Redis error:', err));
await redisClient.connect();
// Sessione con Redis store
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000 // 24 ore
}
}));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(passport.initialize());
app.use(passport.session());
app.use('/auth', authRouter);
app.use('/profile', profileRouter);
app.get('/', (req, res) => {
res.json({ authenticated: req.isAuthenticated(), user: req.user || null });
});
app.listen(PORT, () => console.log(`Server avviato su http://localhost:${PORT}`));
Le tre flag del cookie, httpOnly, secure e sameSite, lavorano in sinergia. httpOnly impedisce a JavaScript (inclusi script XSS) di leggere il cookie di sessione. secure garantisce che il cookie viaggi solo su HTTPS. sameSite: 'lax' blocca le richieste cross-site non intenzionali senza rompere i link normali da email o siti esterni. In produzione con requisiti di sicurezza elevati, considera sameSite: 'strict', ma attenzione: rompe il reindirizzamento OAuth2 quando arriva da una pagina esterna.
Step 5: Configura Passport.js con Google OAuth2
Passport.js usa il concetto di “strategy”: ogni provider ha la propria. La strategy GoogleStrategy implementa l’intero flusso Authorization Code con PKCE. Il file src/auth/passport.js:
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: `${process.env.BASE_URL}/auth/google/callback`,
scope: ['openid', 'profile', 'email'],
pkce: true, // PKCE abilitato (passport-google-oauth20 v2.0+)
state: true // Protezione CSRF tramite state parameter
}, async (accessToken, refreshToken, idTokenData, profile, done) => {
// Normalizza il profilo utente
const user = {
id: profile.id,
email: profile.emails?.[0]?.value,
name: profile.displayName,
picture: profile.photos?.[0]?.value
// Non salvare accessToken in database non cifrato
};
if (!user.email) {
return done(new Error('Nessuna email associata all account Google'));
}
// In produzione: cerca o crea l'utente nel database
// const dbUser = await upsertUser(user);
// return done(null, dbUser);
return done(null, user);
}));
// Serializzazione: salva solo l'ID nella sessione, non l'intero profilo
passport.serializeUser((user, done) => {
done(null, user.id);
});
// Deserializzazione: ricostruisce l'utente a ogni richiesta autenticata
passport.deserializeUser(async (id, done) => {
// In produzione: const user = await getUserById(id);
done(null, { id });
});
La deserializzazione chiama il database a ogni richiesta autenticata. Con migliaia di utenti concorrenti, aggiungi un cache layer Redis con TTL di 5 minuti per i dati utente non critici. La serializzazione deve salvare il minimo indispensabile: solo l’ID, non l’intero profilo con email e foto. Meno dati in sessione, minore la superficie esposta in caso di session hijacking.
Step 6: Implementa le Route di Autenticazione
Le route di autenticazione coprono quattro endpoint: avvio del flusso OAuth2, callback di Google, logout e status. Il file src/routes/auth.js:
import express from 'express';
import passport from 'passport';
const router = express.Router();
// Avvia il flusso OAuth2 con PKCE
router.get('/google', passport.authenticate('google', {
scope: ['openid', 'profile', 'email'],
accessType: 'offline', // Richiede refresh_token da Google
prompt: 'consent' // Forza il consenso per ottenere refresh_token
}));
// Callback di Google dopo autenticazione
router.get('/google/callback',
passport.authenticate('google', {
failureRedirect: '/?error=auth_failed',
failureMessage: true
}),
(req, res) => {
// Rigenera l'ID sessione dopo il login (prevenzione session fixation)
req.session.regenerate((err) => {
if (err) return res.redirect('/?error=session_error');
req.session.user = req.user;
req.session.save((err) => {
if (err) return res.redirect('/?error=session_save');
res.redirect('/profile');
});
});
}
);
// Logout sicuro con distruzione sessione
router.post('/logout', (req, res, next) => {
req.logout((err) => {
if (err) return next(err);
req.session.destroy((err) => {
if (err) return next(err);
res.clearCookie('connect.sid');
res.json({ success: true });
});
});
});
// Stato autenticazione per il frontend
router.get('/status', (req, res) => {
res.json({
authenticated: req.isAuthenticated(),
user: req.user ? { id: req.user.id, email: req.user.email } : null
});
});
export default router;
La rigenerazione dell’ID sessione dopo il login con req.session.regenerate() previene il session fixation attack: un attaccante potrebbe fissare un ID sessione noto prima del login e usarlo per accedere all’account dopo l’autenticazione. Senza regenerate(), l’ID sessione rimane lo stesso prima e dopo il login, rendendo possibile l’attacco. Authgear classifica questa come pratica obbligatoria per le app Node.js nel 2026.
Step 7: Protezione delle Route con Middleware
Il file src/middleware/requireAuth.js definisce due middleware riutilizzabili per la protezione delle route:
// Blocca le richieste non autenticate
export function requireAuth(req, res, next) {
if (req.isAuthenticated()) return next();
// Per richieste API: risponde con 401 JSON
if (req.headers.accept?.includes('application/json')) {
return res.status(401).json({ error: 'Authentication required' });
}
// Per richieste web: salva la destinazione e reindirizza al login
req.session.returnTo = req.originalUrl;
res.redirect('/auth/google');
}
// Reindirizza gli utenti già autenticati (es. pagina di login)
export function requireGuest(req, res, next) {
if (!req.isAuthenticated()) return next();
res.redirect('/profile');
}
Il file src/routes/profile.js applica il middleware alle route protette:
import express from 'express';
import { requireAuth } from '../middleware/requireAuth.js';
const router = express.Router();
router.get('/', requireAuth, (req, res) => {
res.json({
message: 'Profilo protetto',
user: {
id: req.user.id,
email: req.user.email,
name: req.user.name
}
});
});
// Endpoint API protetta per dati sensibili
router.get('/api/data', requireAuth, async (req, res) => {
try {
res.json({ data: 'Dati riservati', userId: req.user.id });
} catch (error) {
console.error('Errore profile/api/data:', error);
// Non esporre stack trace nelle risposte di errore
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
Step 8: Verifica dell’ID Token OIDC con jose
Quando il provider supporta OpenID Connect, ricevi un id_token oltre all’access_token. Questo JWT contiene le claims sull’identità dell’utente. La libreria jose v6 permette di verificarlo controllando firma, issuer, audience e scadenza. Crea il file src/auth/oidc.js:
import { createRemoteJWKSet, jwtVerify } from 'jose';
// Chiavi pubbliche di Google per la verifica delle firme
const GOOGLE_JWKS = createRemoteJWKSet(
new URL('https://www.googleapis.com/oauth2/v3/certs')
);
export async function verifyGoogleIdToken(idToken) {
const { payload } = await jwtVerify(idToken, GOOGLE_JWKS, {
issuer: 'https://accounts.google.com',
audience: process.env.GOOGLE_CLIENT_ID
// jose verifica automaticamente la scadenza (exp claim)
});
// Verifica claims obbligatorie OIDC Core 1.0
if (!payload.sub) throw new Error('ID token privo di sub claim');
if (!payload.email_verified) throw new Error('Email non verificata dal provider');
return {
sub: payload.sub,
email: payload.email,
name: payload.name,
picture: payload.picture,
issuedAt: payload.iat,
expiresAt: payload.exp
};
}
Due punti critici sulla verifica. Primo: specifica sempre l’issuer e l’audience nella chiamata jwtVerify(). Un token emesso per un’altra applicazione non deve essere accettato dalla tua. Secondo: la libreria jose v6 rifiuta automaticamente alg: none e gli algoritmi simmetrici su chiavi asimmetriche, eliminando la categoria di algoritm confusion attacks. Il pinning degli algoritmi, raccomandato dalla guida di Authgear 2026, è già integrato nel comportamento di default di jose quando usi JWKS remoti.
Step 9: Implementa PKCE Manuale per Provider Custom
Se integri un provider personalizzato (Keycloak, Auth0 con piano gratuito, Okta) che non ha una Passport strategy pronta, implementa PKCE con il modulo crittografico nativo di Node.js. Il file src/auth/pkce.js:
import { randomBytes, createHash } from 'node:crypto';
// Genera un code_verifier casuale (RFC 7636: 43-128 caratteri URL-safe)
export function generateCodeVerifier() {
return randomBytes(32).toString('base64url');
}
// Calcola il code_challenge (SHA-256 del verifier)
export function generateCodeChallenge(verifier) {
return createHash('sha256').update(verifier).digest('base64url');
}
// Costruisce l'URL di autorizzazione con PKCE e state anti-CSRF
export function buildAuthorizationUrl(config) {
const verifier = generateCodeVerifier();
const challenge = generateCodeChallenge(verifier);
const state = randomBytes(16).toString('hex');
const params = new URLSearchParams({
response_type: 'code',
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: config.scope,
state,
code_challenge: challenge,
code_challenge_method: 'S256'
});
return {
url: `${config.authorizationEndpoint}?${params}`,
verifier, // Salvare in sessione lato server
state
};
}
Il code_verifier deve essere salvato nella sessione del server, non passato al browser in nessuna forma. Il browser vede solo il code_challenge, un hash SHA-256 irreversibile. Quando arriva il callback con il codice di autorizzazione, il server recupera il verifier dalla sessione e lo invia al token endpoint. Il formato base64url, senza padding e senza caratteri + o /, è obbligatorio per RFC 7636.
Step 10: Gestione Sicura dei Refresh Token
Gli access token Google scadono dopo 3600 secondi (1 ora). Il refresh token permette di ottenerne uno nuovo senza chiedere all’utente di riautenticarsi. La refresh token rotation invalida il token usato ogni volta che viene scambiato, limitando la finestra di abuso se viene rubato. Crea il file src/auth/tokenRefresh.js:
export async function refreshAccessToken(refreshToken) {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: refreshToken
})
});
if (!response.ok) {
const error = await response.json();
// invalid_grant = token revocato o scaduto
if (error.error === 'invalid_grant') {
throw Object.assign(new Error('SESSION_EXPIRED'), { code: 'INVALID_GRANT' });
}
throw new Error(`Token refresh fallito: ${error.error}`);
}
const tokens = await response.json();
return {
accessToken: tokens.access_token,
expiresAt: Date.now() + (tokens.expires_in * 1000),
// Google non sempre emette un nuovo refresh_token
refreshToken: tokens.refresh_token || refreshToken
};
}
Dove salvare i token? Non nel database in chiaro. Le opzioni sicure nel 2026 sono: cifratura AES-256-GCM prima della persistenza, con la chiave master in un secrets manager (AWS Secrets Manager, HashiCorp Vault), oppure mantenerli solo in Redis cifrato con TTL. Non usare mai localStorage per i token: qualsiasi script XSS può leggerlo. Il pattern BFF (Backend for Frontend) è la soluzione ottimale per le SPA: il backend gestisce tutti i token e il browser interagisce solo tramite cookie httpOnly.
Step 11: Logout Sicuro con Revoca Token
Un logout parziale (solo lato client) lascia il refresh token valido anche dopo che l’utente ha cliccato “Esci”. Per un logout sicuro completo bisogna: revocare i token lato authorization server, chiamare req.logout(), distruggere la sessione Redis e cancellare il cookie. Aggiorna la route logout in src/routes/auth.js:
router.post('/logout', async (req, res, next) => {
const accessToken = req.session?.accessToken;
try {
// 1. Revoca il token su Google
if (accessToken) {
await fetch(`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(accessToken)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
}
// 2. Logout Passport (rimuove req.user)
await new Promise((resolve, reject) => {
req.logout((err) => err ? reject(err) : resolve());
});
// 3. Distruggi la sessione Redis
await new Promise((resolve, reject) => {
req.session.destroy((err) => err ? reject(err) : resolve());
});
// 4. Cancella il cookie lato client
res.clearCookie('connect.sid', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax'
});
res.json({ success: true, message: 'Logout effettuato correttamente' });
} catch (error) {
console.error('Errore durante il logout:', error.message);
next(error);
}
});
Step 12: Test Funzionale e Checklist di Sicurezza
Prima del deploy, esegui questi test manuali nell’ordine indicato. Ogni passo verifica un aspetto distinto del flusso:
# Avvia il server in modalità sviluppo
npm run dev
# Test 1: verifica endpoint non autenticato
curl http://localhost:3000/auth/status
# Atteso: {"authenticated":false,"user":null}
# Test 2: verifica protezione route senza sessione
curl -X GET http://localhost:3000/profile -H "Accept: application/json"
# Atteso: {"error":"Authentication required"}
# Test 3: avvia il flusso OAuth2 (apri nel browser)
# Naviga su: http://localhost:3000/auth/google
# Accedi con un account Google di test
# Test 4: verifica sessione attiva dopo login
curl -b "connect.sid=COOKIE_VALUE" http://localhost:3000/auth/status
# Atteso: {"authenticated":true,"user":{"id":"...","email":"..."}}
# Test 5: verifica accesso alla route protetta
curl -b "connect.sid=COOKIE_VALUE" http://localhost:3000/profile
# Atteso: {"message":"Profilo protetto","user":{...}}
# Test 6: logout e verifica invalidazione
curl -X POST -b "connect.sid=COOKIE_VALUE" http://localhost:3000/auth/logout
curl -b "connect.sid=COOKIE_VALUE" http://localhost:3000/auth/status
# Atteso dopo logout: {"authenticated":false,"user":null}
Usa Chrome DevTools (scheda Network, poi Application > Cookies) per verificare i cookie: il cookie di sessione deve avere i flag HttpOnly e SameSite: Lax, e Secure in produzione su HTTPS. Se uno di questi manca, c’è un problema nella configurazione di express-session. Per un test di sicurezza più approfondito del flusso OAuth2, usa Burp Suite come proxy per intercettare e ispezionare ogni richiesta del flusso come mostrato nel nostro tutorial Burp Suite.
Errori Comuni e Come Evitarli
Questi sono gli 8 errori più frequenti nelle implementazioni OAuth2 in Node.js, raccolti da revisioni di codice su progetti in produzione:
- Salvare i token in localStorage. JavaScript in qualsiasi script della pagina (inclusi script di terze parti) può leggere
localStorage. Un XSS anche minimo espone tutti i token. Usa sempre cookiehttpOnly. - Non validare il parametro
state. Il parametrostatepreviene i CSRF sull’endpoint di callback. Se non lo confronti con quello salvato in sessione, un attaccante può innescare un login con il proprio codice di autorizzazione e dirottare la sessione della vittima (login CSRF). - Non specificare l’algoritmo nella verifica JWT. L’attacco “alg:none” e la confusion attack tra HS256/RS256 sono documentati da anni. Usa librerie che fanno il pinning automaticamente, come
josev6. - Usare il session store in memoria in produzione.
MemoryStoredi express-session non è adatto alla produzione: perde dati al riavvio e produce memory leak sotto carico. Usa sempre Redis o un database persistente. - Non rigenerare l’ID sessione dopo il login. La session fixation è un attacco noto. Chiama sempre
req.session.regenerate()dopo l’autenticazione riuscita. - Richiedere scope eccessivi. Se la tua app ha bisogno solo dell’email, richiedi solo
openid email. Scope aggiuntivi comegmail.readonlysenza necessità espongono l’app a rischi maggiori e riducono i tassi di conversione. - Non gestire la revoca del refresh token. Un utente può revocare l’accesso dalla sua Google Account. Il server deve gestire
invalid_grante forzare un nuovo login invece di andare in loop di retry. - Logare il valore completo dei token. Un token di accesso nei log è un token esposto. Logga solo metadata: presenza/assenza, scadenza, user ID associato. Mai il valore del token.
Risoluzione dei Problemi: 8 Scenari Frequenti
| Errore / Sintomo | Causa più probabile | Soluzione |
|---|---|---|
redirect_uri_mismatch | URI nella richiesta diverso da quello registrato su Google Cloud Console | Verifica che BASE_URL in .env corrisponda esattamente all’URI registrato, incluso protocollo e porta |
invalid_client | Client ID o Client Secret errato o scaduto | Rigenera le credenziali su Google Cloud Console e aggiorna il file .env |
invalid_grant al refresh | Refresh token revocato dall’utente o scaduto | Cattura il codice INVALID_GRANT, cancella i token salvati e reindirizza al login |
| Sessione persa al riavvio server | MemoryStore in uso invece di Redis | Configura connect-redis come store nella configurazione della sessione |
| Loop di redirect al login | Cookie non impostato per problema SameSite o CORS | In sviluppo: usa sameSite: 'lax'; verifica che il callback URL non venga da un dominio diverso |
passport.authenticate non esegue il callback | Mancata registrazione di serializeUser o deserializeUser | Controlla che entrambi siano registrati in passport.js prima dell’importazione delle route |
| ID token con firma invalida | Chiavi JWKS scadute in cache o algoritmo non supportato | Usa createRemoteJWKSet di jose per aggiornamento automatico; verifica che il provider usi RS256 |
Cannot read properties of undefined (reading 'emails') | Profilo Google senza email (account senza verifica) | Aggiungi guardia: if (!profile.emails?.length) return done(new Error('No email')) |
Debug del Flusso OAuth2 Passo per Passo
Se non riesci a capire dove il flusso si rompe, abilita il debug di Passport con la variabile d’ambiente DEBUG=passport:* prima di avviare il server:
DEBUG=passport:* node src/app.js
# Output esempio:
# passport:authenticate google +0ms
# passport:strategy:google attempting to authenticate +1ms
# passport:strategy:google redirecting to https://accounts.google.com/o/oauth2/...
# Per ispezionare la sessione Redis:
redis-cli
127.0.0.1:6379> KEYS sess:*
127.0.0.1:6379> GET sess:abc123
# Mostra il JSON della sessione (incluso lo stato dell'autenticazione)
Consigli Avanzati per la Produzione
Una volta che il flusso base funziona, questi accorgimenti portano l’implementazione al livello enterprise:
Rate Limiting sugli Endpoint Auth
Gli endpoint OAuth2 sono bersagli di abuso. Applica rate limiting specifico sul path /auth/*, più restrittivo di quello globale dell’applicazione. Una configurazione ragionevole: massimo 10 richieste per IP ogni 15 minuti. L’articolo Rate Limiting in Node.js mostra l’implementazione completa con Redis e sliding window. Aggiungi anche logging degli IP che superano il limite per identificare pattern di bot.
Validazione con Zod prima del Database
Prima di passare i dati del profilo OAuth2 al database, validali con Zod. Un profilo Google può contenere campi inaspettati o valori fuori range. La validazione con schema previene mass assignment e injection nei layer ORM:
import { z } from 'zod';
const UserProfileSchema = z.object({
id: z.string().min(1).max(255),
email: z.string().email().max(255),
name: z.string().max(255).optional(),
picture: z.string().url().optional()
});
// Nel callback Passport:
const parsed = UserProfileSchema.safeParse(user);
if (!parsed.success) {
return done(new Error('Profilo OAuth2 non valido'));
}
const safeUser = parsed.data;
Multi-provider e account linking. Se permetti login sia con Google che con GitHub, due account possono avere la stessa email. Decidi in anticipo se trattarli come la stessa identità (link account per email) o identità separate. L’approccio più sicuro è richiedere conferma esplicita all’utente prima di collegare due account con la stessa email, per prevenire account takeover tramite provider compromise.
Logging strutturato degli eventi di sicurezza. Logga ogni evento di autenticazione, login riuscito, login fallito, logout, refresh token, con user ID, IP sorgente e timestamp. Non loggare mai il valore dei token. Usa un formato JSON strutturato per poterli analizzare con SIEM (Splunk, Elastic SIEM). Un login riuscito da un’IP mai vista prima merita un alert automatico.
Confronto: Storage Token per le SPA nel 2026
| Metodo di Storage | Resistenza XSS | Resistenza CSRF | Scalabilità | Raccomandazione 2026 |
|---|---|---|---|---|
| localStorage | Nulla | Alta | Alta | Non usare per token |
| sessionStorage | Nulla | Alta | Alta | Non usare per token |
| Cookie httpOnly lax | Alta | Media | Alta | Buono per web app |
| Cookie httpOnly + CSRF token doppio | Alta | Alta | Alta | Ottimale per web app |
| Memoria RAM (SPA in-memory) | Alta | Alta | Bassa (perde al refresh) | Solo per SPA con silent refresh |
| BFF con cookie httpOnly | Alta | Alta | Alta | Ottimale per SPA enterprise |
Il pattern BFF (Backend for Frontend) è la soluzione più robusta per le SPA nel 2026: il backend Node.js gestisce tutta la logica OAuth2 e i token non raggiungono mai il browser in forma leggibile. La SPA interagisce solo con il BFF tramite cookie di sessione httpOnly. Questa architettura elimina l’intera categoria di rischi XSS sui token e semplifica la gestione del refresh, ma aggiunge un componente server-side che le SPA pure evitano.
Copertura Correlata
Articoli sul Tema Sicurezza in Node.js
- JWT Authentication in Node.js: 10 Step per la gestione dei token JSON Web Token senza OAuth2
- CSRF Protection in Node.js: 12 Step per proteggere i form e le API da attacchi cross-site
- Rate Limiting in Node.js: 12 Step per proteggere gli endpoint OAuth2 da abusi e brute force
- OWASP Top 10 2025 in Node.js: 10 Vulnerabilità, 12 Difese per una visione d’insieme delle vulnerabilità applicative
- bcrypt Password Hashing in Node.js per le autenticazioni locali da affiancare a OAuth2
- Burp Suite: Test di Sicurezza Web in 12 Step per intercettare e testare il flusso OAuth2 in dettaglio
FAQ su OAuth 2.0 e OpenID Connect in Node.js
Qual è la differenza pratica tra OAuth2 e OpenID Connect?
OAuth 2.0 è un framework di autorizzazione: definisce come un’applicazione ottiene accesso a risorse per conto di un utente. OpenID Connect è un protocollo di autenticazione costruito sopra OAuth 2.0: aggiunge un ID token JWT con le informazioni sull’identità dell’utente. In pratica, usi OAuth2 per accedere alle API Google (Gmail, Drive) e OIDC per sapere chi è l’utente autenticato.
PKCE è obbligatorio per le web app server-side nel 2026?
RFC 9700 (OAuth 2.0 Security Best Current Practice, 2025) raccomanda PKCE per tutti i client, compresi quelli confidenziali server-side. Non è tecnicamente obbligatorio se il client ha un Client Secret e il canale è sicuro, ma aggiunge un secondo livello di difesa senza costi pratici. Google, GitHub e la maggior parte dei provider moderni supportano PKCE per tutti i tipi di client.
Quanto devono durare gli access token?
La best practice del 2026 è 15 minuti per gli access token con refresh token rotation. Google emette access token con scadenza a 3600 secondi (1 ora), accettabile per la maggior parte delle applicazioni. Token più corti riducono la finestra di esposizione se rubati, ma aumentano il numero di chiamate al token endpoint. Usa refresh token rotation per invalidare ogni token usato.
Posso implementare OAuth2 in Node.js senza Passport.js?
Sì. Puoi implementare il flusso Authorization Code manualmente con node:crypto per PKCE e fetch nativo (disponibile senza flag da Node.js 22+) per le chiamate HTTP. Il pacchetto openid-client di Panva (disponibile su GitHub) è un’alternativa moderna più vicina agli standard OIDC. Passport.js rimane la scelta più diffusa per la vastità del suo ecosistema di strategy.
Come gestisco l’errore “invalid_grant” in produzione?
L’errore invalid_grant significa che il refresh token non è più valido: l’utente ha revocato l’accesso, il token è scaduto dopo 6 mesi di inattività, oppure l’account ha cambiato password. La risposta corretta è: intercetta il codice di errore, cancella i token salvati per quell’utente nel database o in Redis, e reindirizza al login al prossimo accesso. Non fare loop di retry su invalid_grant: è definitivo.
Come integro OAuth2 con il sistema di permessi RBAC esistente?
OAuth2 identifica l’utente; RBAC (Role-Based Access Control) gestisce cosa può fare. Nel callback Passport, dopo aver trovato o creato l’utente nel database, carica i suoi ruoli e includili nel profilo serializzato. Il middleware requireAuth si estende con un requireRole('admin') che legge req.user.roles. Non basarti mai sui ruoli dall’ID token OIDC: quelli vengono da Google, non dal tuo sistema interno.
Come testo l’implementazione OAuth2 senza dipendere da Google?
Usa un authorization server locale per i test automatizzati. Keycloak (disponibile come immagine Docker ufficiale) è l’alternativa enterprise più comune per gli ambienti di staging: avvia in pochi secondi con docker run -p 8080:8080 quay.io/keycloak/keycloak:latest start-dev. Il pacchetto node-oidc-provider è più leggero e adatto ai test di integrazione CI/CD. Entrambi permettono di testare l’intero flusso OAuth2 senza rate limit o dipendenze esterne.
Cosa sono gli scope OIDC standard e quali devo richiedere?
Lo scope openid è obbligatorio per ottenere un ID token OIDC. profile aggiunge nome, foto e dati del profilo. email aggiunge l’indirizzo email e il flag email_verified. offline_access richiede il refresh token (Google usa invece accessType: 'offline'). Richiedi solo gli scope che usi effettivamente: ogni scope aggiuntivo richiede il consenso dell’utente e aumenta la superficie di rischio in caso di token compromise.




