OAuth 2.0 è il protocollo di autorizzazione alla base di ogni “Accedi con Google” o “Login con Microsoft” che usi ogni giorno. Nella sua implementazione classica con il flusso Authorization Code, presenta però una vulnerabilità critica: il codice di autorizzazione può essere intercettato o iniettato da un attaccante prima che arrivi al server legittimo. La soluzione si chiama PKCE (Proof Key for Code Exchange, pronunciato “pixie”), definita nell’RFC 7636 e dal 2025 raccomandata da RFC 9700 per tutti i client OAuth 2.0, inclusi quelli confidenziali che già usano un client secret.
Questa guida mostra come implementare OAuth 2.0 con PKCE in Node.js usando openid-client ed express, partendo dalla configurazione del provider fino alla gestione sicura dei token e al logout corretto. Al termine avrai un’applicazione funzionante pronta per la produzione, conforme alle raccomandazioni di RFC 9700 e allineata ai requisiti di sicurezza dell’identità previsti dalla Direttiva NIS2, recepita in Italia con il D.Lgs. 138/2024.
Cos’è OAuth 2.0 e Perché il Flusso Authorization Code da Solo Non Basta
OAuth 2.0 è un framework di autorizzazione definito nell’RFC 6749 che consente a un’applicazione di ottenere accesso limitato alle risorse di un utente su un server di terze parti, senza mai ricevere le credenziali dell’utente. Quando accedi a un’app con “Accedi con Google” o “Login con GitHub”, stai usando OAuth 2.0. Il protocollo è costruito attorno al concetto di token: l’autorizzazione viene espressa come un access token con scadenza breve, non come username e password persistenti.
Il flusso più sicuro per le applicazioni web è l’Authorization Code Flow: il server di autorizzazione restituisce all’applicazione un codice monouso, che viene poi scambiato con access token e refresh token sul canale back-channel (da server a server), invisibile al browser. Questo evita che i token appaiano nell’URL o nella history del browser.
Il problema sorge su dispositivi mobili e in scenari dove il codice di autorizzazione può essere intercettato prima di raggiungere il client legittimo. Su Android, per esempio, più app possono registrare lo stesso URL scheme personalizzato: un’app malevola può catturare il codice destinato all’app originale. Sui desktop, un malware locale con accesso alle comunicazioni di rete può fare lo stesso. Il risultato in entrambi i casi: l’attaccante scambia il codice intercettato per ottenere i token e accedere alle risorse dell’utente.
Anche nelle applicazioni server-side, un attacco di authorization code injection consente a un avversario di iniettare nel callback di un’applicazione vittima un codice OAuth ottenuto altrove. RFC 9700 identifica questo come uno dei vettori principali contro OAuth 2.0 e ne raccomanda la mitigazione con PKCE obbligatoriamente per tutti i tipi di client.
PKCE (RFC 7636): il Meccanismo di Protezione Spiegato Passo per Passo
PKCE aggiunge un livello crittografico al flusso Authorization Code senza richiedere un client secret aggiuntivo. Il principio è semplice ma efficace: prima di avviare il flusso, il client genera un valore segreto casuale chiamato code_verifier e ne calcola l’hash SHA-256, chiamato code_challenge. Solo l’hash viene inviato al server di autorizzazione all’inizio del flusso. Quando il client scambia il codice con i token, deve presentare il code_verifier originale. Il server verifica che l’hash del code_verifier corrisponda al code_challenge ricevuto in precedenza.
Un intercettatore che cattura il codice di autorizzazione non può completare lo scambio perché non conosce il code_verifier: questo valore non viene mai trasmesso sul canale esposto, solo il suo hash. Il server di autorizzazione rifiuta qualsiasi tentativo di scambio senza il code_verifier corretto, rendendo il codice intercettato inutilizzabile.
Lo stesso meccanismo blocca l’authorization code injection: il codice iniettato da un attaccante era stato originariamente associato a un diverso code_challenge. Il code_verifier della sessione vittima non corrisponderà mai all’hash del codice iniettato, e il server rifiuterà la richiesta.
| Parametro PKCE | Formato e Vincoli | Note di sicurezza |
|---|---|---|
code_verifier | Stringa casuale 43-128 caratteri (A-Z, a-z, 0-9, -, ., _, ~) | Generato con crypto sicuro, mai trasmesso nella prima richiesta |
code_challenge | BASE64URL(SHA256(ASCII(code_verifier))) | Inviato nella richiesta di autorizzazione |
code_challenge_method | S256 (obbligatorio) | Non usare mai plain |
state | Stringa opaca casuale (consigliata ≥ 16 byte) | Protezione CSRF, complementare a PKCE |
redirect_uri | URL esatto registrato sul provider | Matching esatto, zero wildcard |
RFC 9700, pubblicato come standard IETF nel 2024 in aggiornamento di RFC 6819, raccomanda PKCE per tutti i client OAuth 2.0, compresi i client confidenziali che già usano un client secret. PKCE non sostituisce il client secret: i due meccanismi sono complementari. Il client secret autentica il client (dimostra al server che la richiesta proviene dall’applicazione registrata), mentre PKCE protegge il flusso dall’intercettazione e dall’iniezione del codice. L’imminente OAuth 2.1, ancora in stato di draft IETF a giugno 2026, renderà PKCE obbligatorio per tutti i flussi Authorization Code.
4 Attacchi che PKCE Blocca: Guida ai Vettori OAuth 2.0
Prima di scrivere codice, è utile capire concretamente cosa PKCE protegge. Gli attacchi contro OAuth 2.0 documentati in RFC 9700 e RFC 6819 si dividono in quattro categorie principali, con impatto diverso a seconda del tipo di client e dell’ambiente di esecuzione.
L’Authorization Code Interception è il vettore originale per cui PKCE è stato progettato nel 2015 (RFC 7636). Su Android, più app possono registrare lo stesso custom URL scheme. Quando il server di autorizzazione reindirizza con il codice verso il redirect URI, il sistema operativo potrebbe consegnarlo all’app sbagliata. Con PKCE, il codice intercettato è inutilizzabile senza il code_verifier generato dalla sessione originale.
L’Authorization Code Injection è più sofisticato. Un attaccante ottiene un codice di autorizzazione valido (da un suo account su un altro provider, o da una sessione separata) e lo inietta nel flusso di callback di un’applicazione bersaglio sostituendo il parametro code nell’URL di callback. Senza PKCE, il server accetta il codice e rilascia token associati all’account dell’attaccante all’interno della sessione della vittima. Con PKCE, il server verifica che il code_verifier della sessione corrente corrisponda al code_challenge associato al codice iniettato, e rigetta la richiesta.
Il CSRF su OAuth è mitigato principalmente dal parametro state, non da PKCE. Un attaccante costruisce un URL di autorizzazione OAuth completo e induce la vittima a cliccarlo. Senza la verifica dello state, la vittima completa il flusso e si ritrova loggata con l’account dell’attaccante nell’applicazione, che ora ha accesso ai dati inseriti dalla vittima. La verifica dello state nel callback è obbligatoria e complementare a PKCE.
L’Open Redirect Attack sfrutta provider che accettano redirect_uri con wildcard o matching non rigoroso. Un attaccante costruisce un URL di autorizzazione con un redirect_uri controllato da lui, che il provider accetta erroneamente. Dopo l’autenticazione, il codice viene reindirizzato verso il server dell’attaccante. La difesa è la registrazione esatta del redirect_uri sul provider e il suo matching rigoroso.
Prerequisiti: Versioni, Strumenti e Provider OAuth
Per seguire questa guida hai bisogno di Node.js 18 LTS o superiore. La versione 18 è il requisito minimo di openid-client per il supporto nativo ai moduli ES e all’API Web Crypto standard. Verifica la versione installata con node --version; se hai una versione precedente, aggiorna con il Node Version Manager (nvm) usando nvm install 20 && nvm use 20.
Serve anche un provider OAuth 2.0 conforme a OpenID Connect Discovery che supporti PKCE. Le opzioni più comuni per lo sviluppo sono tre. Okta offre un piano gratuito con 100 monthly active users e supporta PKCE nativamente. Auth0 ha un piano gratuito con 7.500 MAU. Google OAuth è disponibile tramite Google Cloud Console senza limiti di MAU per i propri utenti, ma con alcune limitazioni sugli scope. Per ambienti completamente locali, node-oidc-provider implementa un authorization server completo in Node.js.
| Dipendenza | Versione | Scopo |
|---|---|---|
| Node.js | ≥ 18.0.0 (consigliato 20 LTS) | Runtime |
| express | 4.19.2 | Framework HTTP |
| express-session | 1.18.0 | Gestione sessione server-side |
| openid-client | 5.6.5 | Client OIDC/OAuth 2.0 con supporto PKCE nativo |
| dotenv | 16.4.5 | Caricamento variabili d’ambiente |
Questa guida usa openid-client versione 5.x perché è la versione stabile e ampiamente adottata. La versione 6.x ha introdotto breaking change nell’API: se stai usando la v6, l’API di discovery usa client.discovery() invece di Issuer.discover(). I concetti e il flusso PKCE rimangono identici in entrambe le versioni.
Step 1: Configurazione del Provider OAuth (Okta o Google)
Prima di scrivere codice, configura un’applicazione sul tuo provider OAuth. I passaggi variano leggermente tra i provider, ma i dati da raccogliere sono sempre gli stessi: Client ID, Client Secret (per i client confidenziali) e Issuer URL.
Su Okta: accedi alla Developer Console, vai su Applications, crea una nuova App Integration, seleziona OIDC e poi Web Application. Nella sezione Grant Types, assicurati che Authorization Code sia abilitato. Aggiungi http://localhost:3000/authorization-code/callback come Sign-in redirect URI. Annota Client ID, Client Secret e l’Issuer URL nella forma https://your-org.okta.com/oauth2/default.
Su Google OAuth: apri la Google Cloud Console, crea un progetto, vai su APIs & Services, poi Credentials. Crea un OAuth 2.0 Client ID di tipo Web application. Aggiungi http://localhost:3000/authorization-code/callback agli URI di reindirizzamento autorizzati. L’Issuer URL per Google è https://accounts.google.com. Google non fornisce un client secret separato per i client web: usa invece il Client ID e il Client Secret che compaiono nella pagina delle credenziali.
Su entrambi i provider, puoi verificare il documento di discovery OpenID Connect aprendo l’URL {issuer}/.well-known/openid-configuration nel browser. Cerca il campo code_challenge_methods_supported: deve contenere "S256" per confermare che PKCE è supportato.
Step 2: Struttura del Progetto e Installazione delle Dipendenze
Crea una nuova directory e inizializza il progetto Node.js. Usiamo i moduli ES nativi perché openid-client v5 li richiede e perché l’ecosistema Node.js moderno li adotta come standard.
mkdir oauth2-pkce-nodejs && cd oauth2-pkce-nodejs
npm init -y
npm install [email protected] [email protected] [email protected] [email protected]
Modifica package.json per aggiungere il tipo modulo e gli script di avvio:
{
"name": "oauth2-pkce-nodejs",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "node --env-file=.env index.js"
},
"dependencies": {
"dotenv": "16.4.5",
"express": "4.19.2",
"express-session": "1.18.0",
"openid-client": "5.6.5"
},
"engines": {
"node": ">=18.0.0"
}
}
La struttura finale del progetto prevede tre file principali: index.js (server Express), auth.js (logica OAuth e PKCE) e .env (credenziali del provider, mai incluso nel repository Git).
Step 3: Variabili d’Ambiente e Configurazione di Base
Crea il file .env con le credenziali del tuo provider. Aggiungi immediatamente .env al .gitignore: esporre le credenziali OAuth su un repository pubblico è uno degli errori più comuni e più costosi, con codici di autorizzazione e token che possono essere usati in pochi secondi dai bot che scansionano GitHub.
# .env - NON committare questo file
OAUTH_ISSUER=https://your-org.okta.com/oauth2/default
OAUTH_CLIENT_ID=0oaxxxxxxxxxxxxxxxxx
OAUTH_CLIENT_SECRET=il-tuo-client-secret
APP_BASE_URL=http://localhost:3000
POST_LOGOUT_URL=http://localhost:3000/logout-completato
SESSION_SECRET=sostituisci-con-stringa-casuale-di-almeno-32-caratteri
# Aggiungere subito al .gitignore:
# echo ".env" >> .gitignore && echo "node_modules/" >> .gitignore
Il valore SESSION_SECRET deve essere una stringa crittograficamente casuale di almeno 32 caratteri. Generalo con:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Output esempio: a3f8c1e9d4b7f2a6e8c3d9b1f4a7e2c5d8b3f6a9e1c4d7b2f5a8e3c6d9b4f7a
In produzione, gestisci i secret tramite un secrets manager dedicato (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault, Google Secret Manager) e non attraverso variabili d’ambiente hardcoded. Non riutilizzare mai lo stesso SESSION_SECRET tra ambienti diversi (sviluppo, staging, produzione).
Step 4 e 5: Generazione di code_verifier e code_challenge, e Costruzione dell’URL
Crea il file auth.js che gestisce tutta la logica OAuth. Questo file esporta tre funzioni principali: login (avvia il flusso), handleCallback (gestisce il redirect del provider) e logout. Inizia con la configurazione del client OIDC e la funzione di login.
// auth.js
import { Issuer, generators } from 'openid-client';
import 'dotenv/config';
// Cache del client OIDC: la discovery viene eseguita una volta sola
let _client = null;
async function getClient() {
if (_client) return _client;
const issuer = await Issuer.discover(process.env.OAUTH_ISSUER);
_client = new issuer.Client({
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET,
redirect_uris: [`${process.env.APP_BASE_URL}/authorization-code/callback`],
response_types: ['code'],
});
return _client;
}
// Step 4: Genera code_verifier e code_challenge (PKCE)
// Step 5: Costruisce l'URL di autorizzazione e reindirizza
export async function login(req, res, next) {
try {
const client = await getClient();
const code_verifier = generators.codeVerifier(); // 43-128 caratteri casuali (crypto.randomBytes)
const code_challenge = generators.codeChallenge(code_verifier); // BASE64URL(SHA256(code_verifier))
const state = generators.state(); // Protezione CSRF
// Salva in sessione server-side (mai nel cookie del client)
req.session.pkce = { code_verifier, state };
await new Promise((resolve, reject) =>
req.session.save(err => err ? reject(err) : resolve())
);
const authUrl = client.authorizationUrl({
scope: 'openid profile email offline_access',
state,
code_challenge,
code_challenge_method: 'S256', // SHA-256 obbligatorio
redirect_uri: `${process.env.APP_BASE_URL}/authorization-code/callback`,
});
res.redirect(authUrl);
} catch (err) {
next(err);
}
}
Tre punti critici in questo blocco. Primo: generators.codeVerifier() usa internamente crypto.randomBytes(), una fonte di entropia crittografica adeguata. Non usare mai Math.random() per generare valori usati in contesti di sicurezza. Secondo: il code_verifier viene salvato in sessione server-side e non viene mai trasmesso al browser né incluso nei log. Terzo: req.session.save() è chiamato con await prima del redirect per garantire che i dati PKCE siano persistiti prima che il browser venga reindirizzato al provider.
L’URL di autorizzazione generato includerà i parametri PKCE nella query string:
https://your-org.okta.com/oauth2/default/v1/authorize?
client_id=0oaxxxxxxxxxxxxxxxxx
&response_type=code
&scope=openid+profile+email+offline_access
&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauthorization-code%2Fcallback
&state=8xLOxBtZp8
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
Step 6 e 7: Gestione del Callback e Scambio del Codice con i Token
Dopo che l’utente completa l’autenticazione sul provider, viene reindirizzato al tuo redirect_uri con un parametro code nell’URL. Questo codice è monouso, con scadenza tipicamente tra 60 e 300 secondi. Il callback handler deve eseguire quattro operazioni in sequenza: recuperare i dati PKCE dalla sessione, validare il parametro state, presentare il code_verifier per lo scambio, e salvare i token ricevuti.
// Step 6: Gestione del callback OAuth
// Step 7: Scambio codice + code_verifier per i token
export async function handleCallback(req, res, next) {
try {
const client = await getClient();
const params = client.callbackParams(req);
// Verifica state (protezione CSRF)
if (!req.session.pkce || req.session.pkce.state !== params.state) {
return res.status(403).send('Parametro state non valido. Possibile attacco CSRF.');
}
// Scambio codice di autorizzazione + code_verifier per i token
const tokenSet = await client.callback(
`${process.env.APP_BASE_URL}/authorization-code/callback`,
params,
{
code_verifier: req.session.pkce.code_verifier,
state: req.session.pkce.state,
}
);
// openid-client valida automaticamente l'ID Token:
// firma, iss, aud, exp, iat, nonce
const claims = tokenSet.claims();
// Salva i token in sessione server-side
req.session.tokens = {
access_token: tokenSet.access_token,
id_token: tokenSet.id_token,
refresh_token: tokenSet.refresh_token,
expires_at: tokenSet.expires_at, // Unix timestamp
};
req.session.user = {
sub: claims.sub,
email: claims.email,
name: claims.name,
};
// Elimina i dati PKCE dalla sessione (non servono più)
delete req.session.pkce;
await new Promise((resolve, reject) =>
req.session.save(err => err ? reject(err) : resolve())
);
res.redirect('/dashboard');
} catch (err) {
next(err);
}
}
Il metodo client.callback() esegue automaticamente la validazione completa dell’ID Token: verifica la firma crittografica contro la chiave pubblica del provider (recuperata dall’endpoint JWKS), controlla i claim iss (issuer), aud (audience), exp (scadenza), iat (issued at) e il nonce se presente. Non devi implementare questa logica manualmente: la libreria la gestisce per te.
Step 8: Middleware di Autenticazione e Refresh Automatico dei Token
La durata raccomandata per gli access token è tra 15 e 60 minuti. Con access token a vita breve, il middleware di autenticazione deve verificare la scadenza prima di servire ogni richiesta protetta e, se necessario, usare il refresh token per ottenere nuovi token senza richiedere il login all’utente.
// Middleware: verifica autenticazione e aggiorna i token se necessario
export async function requireAuth(req, res, next) {
if (!req.session.tokens || !req.session.user) {
return res.redirect('/login');
}
// Buffer di 60 secondi: aggiorna se scade entro un minuto
const now = Math.floor(Date.now() / 1000);
if (req.session.tokens.expires_at - 60 < now) {
try {
await refreshTokens(req);
} catch (err) {
// Refresh fallito (token revocato o scaduto): re-login obbligatorio
req.session.destroy(() => res.redirect('/login'));
return;
}
}
next();
}
async function refreshTokens(req) {
const client = await getClient();
if (!req.session.tokens.refresh_token) {
throw new Error('Nessun refresh token disponibile');
}
const newTokenSet = await client.refresh(req.session.tokens.refresh_token);
// Alcuni provider ruotano il refresh token: usa il nuovo se disponibile
req.session.tokens = {
access_token: newTokenSet.access_token,
id_token: newTokenSet.id_token ?? req.session.tokens.id_token,
refresh_token: newTokenSet.refresh_token ?? req.session.tokens.refresh_token,
expires_at: newTokenSet.expires_at,
};
await new Promise((resolve, reject) =>
req.session.save(err => err ? reject(err) : resolve())
);
}
Il buffer di 60 secondi prima della scadenza evita che una richiesta API fallisca perché il token scade durante il trasferimento. Per access token con vita di 15 minuti (900 secondi), questo buffer rappresenta il 6,7% della durata totale, un valore proporzionato. Se il refresh fallisce (ad esempio perché il refresh token è stato revocato dall’utente o scaduto), la sessione viene distrutta e l’utente viene reindirizzato al login.
Step 9 e 10: Refresh Token e Logout Sicuro
Il refresh token permette all’applicazione di ottenere nuovi access token senza interrompere l’esperienza dell’utente. Per riceverlo nella risposta iniziale, devi includere lo scope offline_access nella richiesta di autorizzazione. Su Okta, devi anche abilitare esplicitamente il Refresh Token come Grant Type nelle impostazioni dell’applicazione. Su Google, il refresh token viene emesso solo alla prima autorizzazione: nelle sessioni successive, aggiungi prompt: 'consent' all’URL di autorizzazione per forzarne l’emissione.
Alcuni provider implementano il Refresh Token Rotation: ogni uso del refresh token genera un nuovo refresh token e invalida il vecchio. Se un refresh token viene rubato e usato da un attaccante, il tentativo legittimo successivo fallirà, segnalando la compromissione. Il codice deve gestire questa casistica mantenendo il vecchio refresh token se il provider non ne restituisce uno nuovo.
Il logout corretto in OAuth 2.0 richiede due passaggi distinti: la distruzione della sessione locale e il reindirizzamento all’End Session Endpoint del provider (OpenID Connect RP-Initiated Logout). Eseguire solo il logout locale lascia la sessione attiva sul provider: chiunque abbia accesso al browser può riautenticarsi immediatamente senza reinserire le credenziali.
// Step 10: Logout sicuro (locale + provider)
export async function logout(req, res, next) {
try {
const client = await getClient();
const idToken = req.session.tokens?.id_token;
// Costruisce l'URL di end session con id_token_hint
const logoutUrl = client.endSessionUrl({
id_token_hint: idToken,
post_logout_redirect_uri: process.env.POST_LOGOUT_URL,
});
// Prima distruggi la sessione locale, poi reindirizza al provider
req.session.destroy((err) => {
if (err) return next(err);
res.redirect(logoutUrl);
});
} catch (err) {
next(err);
}
}
export function logoutCompletato(req, res) {
res.send(`
Disconnessione completata
Hai effettuato il logout in modo sicuro.
Accedi di nuovo
`);
}
Step 11 e 12: Server Express Completo con Route Protette
Il file index.js assembla tutti i componenti: configurazione della sessione con parametri di sicurezza corretti, routing OAuth e route protette con il middleware requireAuth.
// index.js
import express from 'express';
import session from 'express-session';
import 'dotenv/config';
import {
login,
handleCallback,
logout,
logoutCompletato,
requireAuth,
} from './auth.js';
const app = express();
// Step 12: Configurazione sessione sicura
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // Solo HTTPS in produzione
httpOnly: true, // Inaccessibile da JavaScript lato client
sameSite: 'lax', // Protezione CSRF base per navigazioni normali
maxAge: 24 * 60 * 60 * 1000, // 24 ore
},
}));
app.use(express.json());
// Route OAuth 2.0 + PKCE
app.get('/login', login);
app.get('/authorization-code/callback', handleCallback);
app.get('/logout', logout);
app.get('/logout-completato', logoutCompletato);
// Step 11: Route protette con middleware requireAuth
app.get('/dashboard', requireAuth, (req, res) => {
res.json({
messaggio: 'Dashboard protetta da OAuth 2.0 PKCE',
utente: req.session.user,
token_scade: new Date(req.session.tokens.expires_at * 1000).toISOString(),
});
});
app.get('/profilo', requireAuth, (req, res) => {
res.json(req.session.user);
});
app.get('/', (req, res) => {
const loggato = !!req.session.tokens;
res.send(`
App OAuth 2.0 PKCE
${loggato
? `Ciao, ${req.session.user?.name}! Dashboard | Logout
`
: 'Accedi con OAuth 2.0'
}
`);
});
// Gestione errori globale
app.use((err, req, res, _next) => {
console.error(err.message);
res.status(500).json({ errore: 'Errore interno del server', dettaglio: err.message });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server OAuth 2.0 PKCE avviato su http://localhost:${PORT}`);
});
Quattro punti critici nella configurazione della sessione. Il flag secure: true in produzione garantisce che il cookie di sessione sia trasmesso solo su connessioni HTTPS cifrate. Il flag httpOnly: true impedisce a qualsiasi JavaScript sulla pagina di leggere il cookie di sessione, eliminando il rischio di furto tramite XSS. L’opzione saveUninitialized: false evita di creare sessioni per ogni visitatore non autenticato, riducendo il consumo di memoria e storage. Infine, sameSite: 'lax' fornisce una protezione CSRF di base: il browser non invia il cookie durante navigazioni cross-site iniziate da terze parti, ma lo invia per i redirect top-level normali (come quelli dal provider OAuth).
Archiviazione Sicura dei Token: 3 Pattern a Confronto
La scelta del meccanismo di archiviazione dei token influenza direttamente la sicurezza dell’applicazione. Esistono tre approcci principali con caratteristiche di sicurezza molto diverse, e la scelta dipende dall’architettura (web app tradizionale vs SPA vs app mobile).
La sessione server-side (l’approccio di questa guida) archivia i token in memoria del server o in un database Redis, associati a un session ID opaco inviato al client come cookie httpOnly. Il browser non vede mai il token direttamente: può solo presentare il cookie di sessione. Questo elimina completamente il rischio di furto tramite XSS. Lo svantaggio è la scalabilità: con più istanze del server dietro un load balancer, serve un session store condiviso come Redis o Memcached. L’installazione di connect-redis è semplice e risolve il problema in produzione.
Il Backend For Frontend (BFF) è l’evoluzione moderna per architetture SPA e React/Vue/Angular. Un microservizio Node.js dedicato (il BFF) gestisce interamente il flusso OAuth e mantiene i token lato server, esponendo al frontend solo cookie di sessione sicuri. Ogni chiamata API del frontend passa attraverso il BFF, che aggiunge l’Authorization header prima di inoltrarla all’API reale. È l’approccio raccomandato da OAuth 2.0 Security BCP per le Single Page Application.
Il localStorage o sessionStorage è l’approccio da non usare mai per i token OAuth. Qualsiasi script sulla pagina, inclusi script di analytics, widget di chat o librerie di terze parti, può leggere il contenuto del localStorage. Un attacco XSS che inietta anche una sola riga di codice nella pagina può estrarre tutti i token in millisecondi e trasmetterli a un server remoto. OWASP raccomanda esplicitamente di non archiviare token sensibili nel localStorage.
| Metodo | Rischio XSS | Scalabilità | Complessità | Raccomandato per |
|---|---|---|---|---|
| Sessione server-side | Basso | Richiede Redis | Media | Web app tradizionali |
| Cookie httpOnly cifrato | Molto basso | Alta | Alta | API stateless |
| Backend For Frontend (BFF) | Molto basso | Alta | Alta | SPA (React, Vue, Angular) |
| localStorage | Molto alto | Alta | Bassa | Mai per token OAuth |
| sessionStorage | Alto | N/A | Bassa | Mai per token sensibili |
6 Errori Comuni nell’Implementazione OAuth 2.0 con PKCE
Errore 1: non validare il parametro state nel callback. Molte implementazioni generano il state ma non eseguono la verifica nel callback. Senza questo check, l’applicazione è vulnerabile ad attacchi CSRF: un attaccante può forzare l’utente a completare un flusso OAuth non richiesto. La verifica deve essere esplicita: se i due valori non corrispondono, rispondi con HTTP 403 e non procedere con lo scambio del codice.
Errore 2: usare code_challenge_method plain invece di S256. Il metodo plain trasmette il code_verifier non trasformato come code_challenge. Chiunque osservi la prima richiesta di autorizzazione (log del provider, proxy aziendale, MitM) ottiene il code_verifier direttamente e può completare lo scambio. Il metodo S256 risolve il problema inviando solo l’hash SHA-256, rendendo il code_verifier computazionalmente non ricavabile dall’hash.
Errore 3: archiviare i token in localStorage o variabili JavaScript. Come descritto nella sezione precedente, qualsiasi script sulla pagina può accedere al localStorage. Uno script malevolo iniettato tramite XSS può estrarre i token e trasmetterli a un server remoto. La soluzione è la sessione server-side con cookie httpOnly.
Errore 4: redirect_uri con wildcard o matching non esatto. Se il provider accetta redirect_uri con prefissi o wildcard, un attaccante può costruire un URL di autorizzazione con un redirect_uri controllato da lui. Dopo l’autenticazione, il codice viene inviato al server dell’attaccante. Registra sempre l’URL completo e letterale: https://app.esempio.it/callback, non https://app.esempio.it/*. Verifica che il tuo provider implementi il matching rigoroso.
Errore 5: non eliminare i dati PKCE dalla sessione dopo il callback. Il code_verifier e lo state sono monouso: dopo il callback completato con successo, non hanno più utilità. Lasciarli in sessione aumenta inutilmente il volume dei dati nella sessione e potrebbe creare confusione in flussi di login multipli paralleli dalla stessa sessione. Rimuovili con delete req.session.pkce immediatamente dopo il callback riuscito.
Errore 6: non chiamare req.session.save() con await prima del redirect. Express-session salva la sessione in modo asincrono. Se chiami res.redirect() immediatamente dopo aver modificato la sessione senza attendere il completamento del salvataggio, il browser può ricevere il redirect prima che i dati PKCE siano stati persistiti. Quando il provider reindirizza al callback, la sessione non conterrà il code_verifier e l’autenticazione fallirà con un errore non ovvio. Usa sempre await req.session.save() prima di qualsiasi redirect critico.
Troubleshooting: 8 Errori Frequenti e Come Risolverli
1. “invalid_grant” durante lo scambio del codice. Questo errore indica che il codice di autorizzazione è scaduto (tipicamente entro 60-300 secondi), già usato in precedenza, o che il code_verifier non corrisponde al code_challenge. Prima di tutto, verifica che il code_verifier sia salvato correttamente nella sessione. Se usi un load balancer senza sticky sessions, la richiesta di callback potrebbe arrivare a un’istanza diversa da quella che ha gestito il login, dove la sessione in memoria non esiste. La soluzione è Redis come session store condiviso.
2. “redirect_uri_mismatch”. Il redirect_uri nel codice non corrisponde esattamente a quello registrato sul provider. Controlla ogni carattere: il protocollo (http vs https), il numero di porta, le maiuscole/minuscole, lo slash finale. Anche un parametro query inatteso nell’URL di callback causa questo errore su provider rigidi. Apri il documento di discovery del provider per vedere le regole di matching.
3. State non trovato in sessione nel callback. La sessione esiste ma è vuota o il campo pkce è assente. Cause frequenti: il cookie di sessione non viene inviato al provider perché secure: true è attivo su HTTP locale; il sameSite: 'strict' blocca il cookie durante il redirect cross-site dal provider; req.session.save() non è stato atteso prima del redirect. In sviluppo locale, usa secure: false e sameSite: 'lax'.
4. “code_challenge_method not supported”. Il provider non supporta PKCE o non supporta S256. Verifica il campo code_challenge_methods_supported nel documento di discovery del provider. Se il campo è assente o contiene solo plain, il provider non è conforme alle raccomandazioni RFC 9700 e va aggiornato o sostituito.
5. “JWT expired” nella validazione dell’ID Token. Indica uno skew temporale tra il tuo server e il server del provider. openid-client accetta per default 30 secondi di tolleranza. Puoi aumentarla con client[custom.clockTolerance] = 60, ma la soluzione duratura è sincronizzare il clock del server tramite NTP. In produzione, sistemi come AWS EC2 o Google Compute Engine sincronizzano il clock automaticamente.
6. Refresh token non presente nella risposta. Il refresh token viene emesso solo con lo scope offline_access e solo se il provider è configurato per emetterlo. Su Okta, verifica che “Refresh Token” sia abilitato in Grant Types nelle impostazioni dell’applicazione. Su Google, il refresh token viene emesso solo alla prima autorizzazione; aggiungi access_type: 'offline' e prompt: 'consent' all’URL di autorizzazione per forzarne l’emissione nelle sessioni successive.
7. “Cannot read properties of undefined” su req.session.pkce. La sessione esiste ma il campo pkce non è mai stato impostato o è stato eliminato prematuramente. Verifica che la route di login imposti effettivamente req.session.pkce e che req.session.save() sia stato atteso prima del redirect al provider. Se il problema persiste, aggiungi un log prima del redirect per verificare il contenuto della sessione.
8. Il logout locale funziona ma l’utente rimane loggato sul provider. Hai distrutto la sessione locale ma non hai reindirizzato verso l’End Session Endpoint del provider. Verifica che il provider supporti OpenID Connect RP-Initiated Logout (campo end_session_endpoint nel documento di discovery). Non tutti i provider lo implementano: su GitHub OAuth, per esempio, non esiste un endpoint di logout standard. Se il provider non supporta l’end session, informa l’utente di chiudere manualmente la sessione nel browser del provider.
Consigli Avanzati: Multi-Provider, Redis e Conformità NIS2
In un’applicazione reale potresti dover supportare più provider OAuth contemporaneamente (Google, Microsoft Azure AD, GitHub, Okta). La libreria openid-client lo gestisce bene: ogni provider espone il proprio documento di discovery OpenID Connect all’URL {issuer}/.well-known/openid-configuration, e puoi istanziare un client separato per ciascuno. Mantieni una mappa di client in cache, indicizzati per provider name, e seleziona il client corretto in base a un parametro nella richiesta di login (ad esempio /login?provider=google).
Per il deployment in produzione con più istanze del server, sostituisci il session store in-memory con Redis usando connect-redis. Installa connect-redis e il client Redis (redis), crea un client Redis con la configurazione del tuo cluster, e passa lo store come opzione a express-session. Con Redis come session store, le sessioni sopravvivono ai riavvii del processo Node.js e funzionano correttamente con load balancer che non implementano sticky sessions.
Per le organizzazioni soggette alla Direttiva NIS2 (recepita in Italia con il D.Lgs. 138/2024 che coinvolge circa 12.000 aziende), l’implementazione di OAuth 2.0 con PKCE contribuisce ai controlli di sicurezza relativi alla gestione degli accessi e all’autenticazione. RFC 9700 e le raccomandazioni PKCE sono allineate ai requisiti NIS2 per l’autenticazione forte e la gestione sicura delle identità digitali. Abbinato all’autenticazione a due fattori (2FA in Node.js), OAuth 2.0 con PKCE soddisfa i requisiti di MFA per le entità essenziali e importanti secondo l’Allegato I del D.Lgs. 138/2024.
Per le applicazioni con requisiti di sicurezza elevati, considera l’aggiunta di DPoP (Demonstration of Proof-of-Possession), definito nell’RFC 9449. DPoP lega l’access token alla chiave privata del client, rendendo i token rubati inutilizzabili su altri dispositivi. openid-client supporta DPoP nativamente: aggiungi la generazione della chiave DPoP prima del login e includila nelle richieste token.
Output di Esempio: Flusso Completo dall’Inizio al Token
Ecco l’output che dovresti vedere nel terminale durante un’autenticazione OAuth 2.0 PKCE completata con successo, utile per verificare che ogni passaggio funzioni correttamente.
# Avvio server
$ node --env-file=.env index.js
Server OAuth 2.0 PKCE avviato su http://localhost:3000
# Step 1: Utente visita /login
GET /login 302 - Reindirizzamento a Okta
code_verifier generato: 128 caratteri
code_challenge (S256): E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
state salvato in sessione: aBcDeFgHiJ1234567890
# Step 2: Utente autentica su Okta (credenziali + eventuale MFA)
# Step 3: Okta reindirizza a callback con codice monouso
GET /authorization-code/callback?code=XXXXXXXXXXX&state=aBcDeFgHiJ1234567890 -
# Step 4: Validazione state: OK (corrisponde alla sessione)
# Step 5: Scambio codice + code_verifier per token
access_token ricevuto: eyJhbGciOiJSUzI1NiJ9... (JWT, 900 secondi)
id_token validato: sub=00uXXXX, [email protected], name=Mario Rossi
refresh_token ricevuto: def502...
expires_at: 1750425600 (15:00:00 UTC)
# Step 6: Sessione aggiornata, reindirizzamento a /dashboard
GET /dashboard 200
{
"messaggio": "Dashboard protetta da OAuth 2.0 PKCE",
"utente": { "sub": "00uXXXX", "email": "[email protected]", "name": "Mario Rossi" },
"token_scade": "2026-06-20T15:00:00.000Z"
}
# Dopo 15 minuti: refresh automatico trasparente
GET /profilo - Token in scadenza, refresh automatico...
Nuovo access_token emesso, expires_at aggiornato
GET /profilo 200 - { "sub": "00uXXXX", "email": "[email protected]" }
FAQ: OAuth 2.0 e PKCE in Node.js
PKCE sostituisce il client secret? No. PKCE e il client secret sono meccanismi complementari con scopi diversi. Il client secret autentica l’applicazione client presso il server di autorizzazione. PKCE protegge il flusso di autorizzazione dall’intercettazione e dall’iniezione del codice. RFC 9700 raccomanda di usare entrambi nei client confidenziali: il secret per l’autenticazione del client, PKCE per la sicurezza del flusso.
OAuth 2.0 e OpenID Connect sono la stessa cosa? No, anche se vengono spesso usati insieme. OAuth 2.0 è un framework di autorizzazione che gestisce i permessi di accesso alle risorse. OpenID Connect (OIDC) è un layer di identità costruito sopra OAuth 2.0 che aggiunge l’ID Token (un JWT con informazioni sull’utente) e l’endpoint userinfo per l’autenticazione. Quando includi openid nello scope OAuth, stai usando OIDC sopra OAuth 2.0.
Qual è la differenza tra access token e ID token? L’access token autorizza le chiamate alle API protette: va incluso nell’header Authorization: Bearer {token}. L’ID token contiene informazioni sull’identità dell’utente (nome, email, sub): viene usato solo dal client per conoscere chi si è autenticato, non va mai inviato alle API di terze parti. Condividere l’ID token con le API è una vulnerabilità di sicurezza.
È sicuro usare OAuth 2.0 su HTTP in sviluppo locale? Su localhost, HTTP è accettabile in sviluppo perché il traffico non esce dal computer. Molti provider e browser trattano localhost come un’origine sicura. In produzione, HTTPS è obbligatorio e non negoziabile: il codice di autorizzazione e i token non devono mai transitare su connessioni non cifrate. Usa sempre secure: true per i cookie in produzione.
Come gestire OAuth 2.0 con un frontend React separato dal backend Node.js? Il pattern corretto è il Backend For Frontend (BFF): il frontend React/Vue parla esclusivamente con il backend Node.js tramite chiamate API, senza mai gestire token direttamente. Il BFF gestisce il flusso OAuth, mantiene i token lato server, e restituisce al frontend solo cookie di sessione httpOnly. Le chiamate API del frontend passano attraverso il BFF, che aggiunge l’Authorization header prima di inoltrarle all’API reale.
Quanto devono durare gli access token? RFC 9700 raccomanda access token a vita breve: tra 15 e 60 minuti. Per API con dati particolarmente sensibili, 5-15 minuti. I refresh token possono avere vita più lunga (ore, giorni o anche più a seconda del provider), con revoca immediata se compromessi. L’obiettivo è minimizzare la finestra temporale in cui un token rubato è utilizzabile.
OAuth 2.1 è già disponibile? A giugno 2026, OAuth 2.1 è ancora in stato di draft IETF. Tuttavia, le sue raccomandazioni principali (PKCE obbligatorio per tutti i client, eliminazione del Implicit Flow e del Password Grant, PKCE anche per i client confidenziali) sono già incorporate in RFC 9700, uno standard pubblicato. Implementare questa guida oggi significa essere già conformi a OAuth 2.1 quando diventerà standard definitivo.
Come verificare che il provider supporti PKCE? Apri il documento di discovery OpenID Connect del provider all’URL {issuer}/.well-known/openid-configuration e cerca il campo code_challenge_methods_supported. Deve contenere "S256". Se il campo è assente, prova a fare una richiesta di autorizzazione con i parametri PKCE: un provider conforme accetterà la richiesta anche senza dichiararlo esplicitamente nel discovery.
Letture Correlate
Per approfondire la sicurezza delle applicazioni Node.js e la gestione delle identità:
- JWT Authentication in Node.js: 10 Step per Implementarla Correttamente
- Two-Factor Authentication in Node.js: 11 Step con TOTP [2026]
- CSRF Protection in Node.js: 12 Step per Proteggere le Form
- Node.js Session Management: 11 Step per Sessioni Sicure
- OWASP Top 10 in Node.js: 12 Step per Proteggere la tua API
- Passkey vs Password: 8,5s vs 31s di Sign-In nel 2026
- XSS in Node.js: Prevenirlo in 12 Step
Specifiche ufficiali e risorse tecniche esterne:




