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.0Caso d’usoPKCE richiestoLivello di sicurezza
Authorization Code + PKCEWeb app, SPA, mobileSiAlto
Authorization Code (senza PKCE)Web app server-side legacyNo (sconsigliato)Medio
Client CredentialsM2M, microserviziNoAlto (per M2M)
Device AuthorizationSmart TV, CLINoMedio
Implicit FlowDeprecato dal 2019N/ABasso (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:

PacchettoVersioneScopo
express^5.xFramework HTTP
passport^0.7.xMiddleware autenticazione
passport-google-oauth20^2.0.xStrategy OAuth2 Google
express-session^1.18.xGestione sessioni lato server
connect-redis^8.xStore sessioni Redis
jose^6.xVerifica JWT e ID token OIDC
dotenv^16.xVariabili d’ambiente
crypto (built-in)N/AGenerazione 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:

  1. Il server genera un code_verifier casuale (32 byte, 256 bit di entropia) e ne calcola il code_challenge con SHA-256.
  2. Il server reindirizza l’utente all’authorization server inviando code_challenge e code_challenge_method=S256.
  3. L’utente si autentica e l’authorization server rilascia un codice di autorizzazione monouso.
  4. Il browser invia il codice di autorizzazione al server Node.js tramite il redirect URI.
  5. Il server Node.js scambia il codice con i token inviando anche il code_verifier originale.
  6. L’authorization server verifica il verifier contro il challenge e rilascia access_token, refresh_token e (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:

  1. 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 cookie httpOnly.
  2. Non validare il parametro state. Il parametro state previene 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).
  3. 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 jose v6.
  4. Usare il session store in memoria in produzione. MemoryStore di express-session non è adatto alla produzione: perde dati al riavvio e produce memory leak sotto carico. Usa sempre Redis o un database persistente.
  5. Non rigenerare l’ID sessione dopo il login. La session fixation è un attacco noto. Chiama sempre req.session.regenerate() dopo l’autenticazione riuscita.
  6. Richiedere scope eccessivi. Se la tua app ha bisogno solo dell’email, richiedi solo openid email. Scope aggiuntivi come gmail.readonly senza necessità espongono l’app a rischi maggiori e riducono i tassi di conversione.
  7. Non gestire la revoca del refresh token. Un utente può revocare l’accesso dalla sua Google Account. Il server deve gestire invalid_grant e forzare un nuovo login invece di andare in loop di retry.
  8. 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 / SintomoCausa più probabileSoluzione
redirect_uri_mismatchURI nella richiesta diverso da quello registrato su Google Cloud ConsoleVerifica che BASE_URL in .env corrisponda esattamente all’URI registrato, incluso protocollo e porta
invalid_clientClient ID o Client Secret errato o scadutoRigenera le credenziali su Google Cloud Console e aggiorna il file .env
invalid_grant al refreshRefresh token revocato dall’utente o scadutoCattura il codice INVALID_GRANT, cancella i token salvati e reindirizza al login
Sessione persa al riavvio serverMemoryStore in uso invece di RedisConfigura connect-redis come store nella configurazione della sessione
Loop di redirect al loginCookie non impostato per problema SameSite o CORSIn sviluppo: usa sameSite: 'lax'; verifica che il callback URL non venga da un dominio diverso
passport.authenticate non esegue il callbackMancata registrazione di serializeUser o deserializeUserControlla che entrambi siano registrati in passport.js prima dell’importazione delle route
ID token con firma invalidaChiavi JWKS scadute in cache o algoritmo non supportatoUsa 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 StorageResistenza XSSResistenza CSRFScalabilitàRaccomandazione 2026
localStorageNullaAltaAltaNon usare per token
sessionStorageNullaAltaAltaNon usare per token
Cookie httpOnly laxAltaMediaAltaBuono per web app
Cookie httpOnly + CSRF token doppioAltaAltaAltaOttimale per web app
Memoria RAM (SPA in-memory)AltaAltaBassa (perde al refresh)Solo per SPA con silent refresh
BFF con cookie httpOnlyAltaAltaAltaOttimale 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

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.