Salta al contenuto principale

07 - Authentication: JWT, OAuth2, session vs token

Sessione opaca, JWT o OAuth: scopri quanto costa ogni scelta nella revoca e prova in codice la verifica dei token, la rotazione del refresh e PKCE.

Cosa ne pensi di questo articolo?

07 - Authentication: JWT, OAuth2, session vs token
Sviluppo Web Fullstackfedericocalo.dev

In Breve

Sessione opaca, JWT o OAuth: scopri quanto costa ogni scelta nella revoca e prova in codice la verifica dei token, la rotazione del refresh e PKCE.

Sessione, JWT o OAuth: prima di scegliere, decidi dove sta lo stato

«Meglio la sessione o il JWT?» è la domanda sbagliata da cui partire, perché confonde tre cose. Una sessione opaca è un identificatore casuale che il server tiene in una tabella: lo stato sta sul server, e revocarla significa cancellare una riga. Un JWT è un insieme di dichiarazioni firmate che chiunque possieda la chiave può verificare senza interrogare nessuno: lo stato sta nel token, e revocarlo prima della scadenza è il problema. OAuth 2.0 non è né l'una né l'altra: è un protocollo di delega, che permette a un'applicazione di agire con un accesso limitato per conto di un utente senza conoscerne la password. Per un'applicazione web che parla con il proprio backend, la sessione opaca in un cookie è, a mio giudizio, la strada più semplice (è una valutazione, non un dato); JWT e OAuth entrano quando hai più servizi che non condividono un archivio o un'applicazione di terzi che deve accedere ai dati. Qui costruisci i pezzi su un progetto di prova, in-process, e vedi dove ciascuno si rompe.

Che cosa costa davvero ciascuna scelta: la revoca

Un esempio concreto aiuta. Ada fa login e riceve due cose insieme: una sessione opaca e un JWT con scadenza a 15 minuti (900 secondi, valore scelto per l'esempio). Dopo 100 secondi preme «Esci». Il server cancella la riga della sessione: dal secondo successivo quella sessione non vale più. Il JWT invece non è stato toccato, e il server che lo verifica con la sola firma non ha modo di sapere che Ada è uscita. Nel programma sotto l'uscita lo dice in numeri: la sessione è rifiutata, il token resta valido per altri 800 secondi.

  • Sessione opaca: lo stato sta in un archivio condiviso, quindi ogni richiesta costa una lettura, e la revoca è immediata.
  • JWT firmato: nessuna lettura per verificarlo, ma la revoca prima della scadenza richiede di reintrodurre uno stato (un elenco di token revocati) e quindi la lettura che si voleva evitare.
  • Token a vita breve con refresh token: l'access token resta stateless, e lo stato si sposta nel solo refresh token, controllato raramente.

Le tre righe non sono alternative equivalenti: sono tre punti sulla stessa curva che scambia costo di lettura con prontezza di revoca. Nessuna è «più sicura» in assoluto. La domanda utile è quanto tempo può restare valido un accesso dopo che hai deciso di toglierlo, e quante letture puoi permetterti per ogni richiesta. Se la risposta è «zero secondi» per le operazioni sensibili, serve uno stato consultato a ogni richiesta, qualunque sia il formato del token.

JWT: una firma sull'integrità, non un segreto

Un JWT firmato con HMAC è composto da tre parti in base64url: intestazione, dichiarazioni e firma. La firma garantisce l'integrità, cioè che nessuno abbia cambiato i byte dopo la firma (RFC 7515, sezione 1); non nasconde nulla, perché le dichiarazioni si leggono decodificando il base64url, senza chiave. Per questo nel token non si mettono dati riservati. Una seconda proprietà riguarda l'HMAC: la stessa chiave serve a firmare e a verificare, quindi ogni servizio che può verificare può anche falsificare, e la RFC 7515 (sezione 10.5) spiega che la convalida di un MAC prova soltanto che il messaggio è stato generato da una delle parti che conoscono la chiave. Con più servizi di proprietari diversi si usano firme asimmetriche, che qui non tratto.

Il modulo sotto contiene due verificatori messi a confronto, e il primo è sbagliato di proposito. verificaIngenua legge l'algoritmo dall'intestazione del token e, se è none, restituisce le dichiarazioni senza controllare nulla: è il difetto che l'RFC 8725, sezione 2.1, descrive: un attaccante può cambiare l'algoritmo in none e alcune librerie, fidandosi del valore, davano il JWT per valido senza controllare alcuna firma. verifica invece sceglie l'algoritmo da sé (HS256, e nient'altro, come impone la sezione 3.1 dell'RFC 8725), confronta la firma in tempo costante con timingSafeEqual, e poi controlla scadenza, nbf, emittente e destinatario. Un token senza exp viene rifiutato: la RFC 7519 considera il claim facoltativo, ma qui è una scelta dell'applicazione.

import { createHmac, timingSafeEqual } from 'node:crypto';

const b64u = (dati) => Buffer.from(dati).toString('base64url');
const leggi = (parte) => JSON.parse(Buffer.from(parte, 'base64url').toString('utf8'));
const hmac = (input, chiave) => createHmac('sha256', chiave).update(input).digest();

export function firma(payload, chiave) {
  const input = `${b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64u(JSON.stringify(payload))}`;
  return `${input}.${b64u(hmac(input, chiave))}`;
}

// ERRATO: si fida dell'algoritmo dichiarato dal token, che e' scritto da chi lo presenta.
export function verificaIngenua(token, chiave) {
  const [h, p, s] = token.split('.');
  if (leggi(h).alg === 'none') return leggi(p);
  if (b64u(hmac(`${h}.${p}`, chiave)) !== s) throw new Error('firma non valida');
  return leggi(p);
}

// GIUSTO: l'algoritmo lo sceglie il server; firma, scadenza, emittente e destinatario si controllano tutti.
export function verifica(token, chiave, { iss, aud, adesso }) {
  const parti = token.split('.');
  if (parti.length !== 3) throw new Error('formato non valido');
  const [h, p, s] = parti;
  const { alg } = leggi(h);
  if (alg !== 'HS256') throw new Error(`algoritmo non ammesso: ${alg}`);
  const atteso = hmac(`${h}.${p}`, chiave);
  const ricevuto = Buffer.from(s, 'base64url');
  if (ricevuto.length !== atteso.length || !timingSafeEqual(ricevuto, atteso)) throw new Error('firma non valida');
  const c = leggi(p);
  if (typeof c.exp !== 'number' || c.exp <= adesso) throw new Error('scaduto o senza exp');
  if (c.nbf !== undefined && c.nbf > adesso) throw new Error('non ancora valido');
  if (c.iss !== iss) throw new Error('emittente inatteso');
  if (![].concat(c.aud ?? []).includes(aud)) throw new Error('destinatario inatteso');
  return c;
}

La prova che segue fa due cose. Prima verifica che il codice sia corretto con due controlli indipendenti: la firma del vettore di prova dell'RFC 7515 (appendice A.1, con la sua chiave e il suo input) coincide byte per byte con quella attesa, e la libreria jose (versione 6.2.17) verifica un mio token mentre io verifico uno firmato da lei. Poi attacca i due verificatori con quattro token costruiti da me. L'orologio è fisso (ADESSO), così l'uscita non dipende dall'ora della macchina.

import { createHmac } from 'node:crypto';
import { SignJWT, jwtVerify } from 'jose';
import { firma, verifica, verificaIngenua } from './jwt.mjs';

const ADESSO = 1_700_000_000;
const chiave = Buffer.from('0123456789abcdef0123456789abcdef');
const ok = { iss: 'api.example', aud: 'app', adesso: ADESSO };
const claim = { sub: 'ada', ruolo: 'utente', iss: 'api.example', aud: 'app', exp: ADESSO + 900 };
const esito = (f) => { try { f(); return 'accettato'; } catch (e) { return `rifiutato (${e.message})`; } };

// 1. Vettore dell'RFC 7515, appendice A.1: stessa chiave, stesso input, stessa firma attesa.
const chiaveRfc = Buffer.from('AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow', 'base64url');
const inputRfc = 'eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ';
const firmaRfc = createHmac('sha256', chiaveRfc).update(inputRfc).digest('base64url');
console.log('vettore RFC 7515 A.1:', firmaRfc === 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' ? 'firma uguale' : 'DIVERSA');

// 2. Seconda implementazione indipendente: la libreria jose.
const mio = firma(claim, chiave);
const daJose = await jwtVerify(mio, chiave, { algorithms: ['HS256'], issuer: 'api.example', audience: 'app', currentDate: new Date(ADESSO * 1000) });
console.log('jose verifica il mio token:', daJose.payload.sub);
const suo = await new SignJWT({ sub: 'ada' }).setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
  .setIssuer('api.example').setAudience('app').setExpirationTime(ADESSO + 900).sign(chiave);
console.log('io verifico il token di jose:', verifica(suo, chiave, ok).sub);

// 3. Attacchi contro il verificatore ingenuo e contro quello vero.
const senzaFirma = (c) => `${Buffer.from('{"alg":"none"}').toString('base64url')}.${Buffer.from(JSON.stringify(c)).toString('base64url')}.`;
const falso = senzaFirma({ ...claim, ruolo: 'admin' });
const [h, p, s] = mio.split('.');
const manomesso = `${h}.${Buffer.from(JSON.stringify({ ...claim, ruolo: 'admin' })).toString('base64url')}.${s}`;
const casi = [
  ['alg none, ruolo admin', falso],
  ['payload cambiato, firma vecchia', manomesso],
  ['token scaduto', firma({ ...claim, exp: ADESSO - 1 }, chiave)],
  ['destinatario sbagliato', firma({ ...claim, aud: 'altra-app' }, chiave)],
];
for (const [nome, token] of casi) {
  console.log(nome.padEnd(32), '| ingenuo:', esito(() => verificaIngenua(token, chiave)).padEnd(28), '| corretto:', esito(() => verifica(token, chiave, ok)));
}

// Output:
// vettore RFC 7515 A.1: firma uguale
// jose verifica il mio token: ada
// io verifico il token di jose: ada
// alg none, ruolo admin            | ingenuo: accettato                    | corretto: rifiutato (algoritmo non ammesso: none)
// payload cambiato, firma vecchia  | ingenuo: rifiutato (firma non valida) | corretto: rifiutato (firma non valida)
// token scaduto                    | ingenuo: accettato                    | corretto: rifiutato (scaduto o senza exp)
// destinatario sbagliato           | ingenuo: accettato                    | corretto: rifiutato (destinatario inatteso)

La prima riga dell'uscita mostra il vettore dell'RFC confermato; la seconda e la terza mostrano che i due codici si capiscono. Nella tabella il token con alg: none e ruolo admin passa il verificatore ingenuo e viene fermato dal verificatore vero; lo stesso accade con il token scaduto e con quello destinato a un'altra applicazione, che il verificatore ingenuo accetta perché non controlla nulla oltre alla firma. Solo il payload manomesso con la firma vecchia viene rifiutato da entrambi, perché il verificatore ingenuo almeno la firma HS256 la confronta (con un operatore non a tempo costante, un altro difetto). Una libreria come jose ti evita di scrivere tutto questo, ma i difetti sono gli stessi che troverai nelle configurazioni sbagliate, e questa tabella ti dice quali controlli pretendere.

Refresh token: ruotare per accorgersi dei furti

Se l'access token vive 15 minuti, serve un modo per ottenerne uno nuovo senza chiedere ogni volta la password. È il refresh token, e vale più dell'access token, perché permette di coniarne altri. La RFC 9700 (Best Current Practice per la sicurezza di OAuth 2.0, gennaio 2025) impone, per i client pubblici, di rilevare il furto con uno di due metodi: legare il refresh token al client in modo crittografico, oppure ruotarlo, cioè emettere un nuovo refresh token a ogni rinnovo e invalidare il precedente conservandone la parentela. Se un ladro e il client legittimo usano entrambi lo stesso token, uno dei due presenterà un token già invalidato e il server se ne accorge. L'RFC è esplicita sul prezzo: il server non può sapere chi dei due ha presentato quel token, revoca il token attivo e il client legittimo deve ripetere l'autorizzazione.

Il modello in memoria sotto riproduce il caso, insieme al confronto fra sessione e JWT di prima. Gli identificatori sono contatori e non valori casuali, per rendere riproducibile l'uscita: in un sistema vero sono valori imprevedibili. Il modello non è un'implementazione da copiare: manca la persistenza, la scadenza per inattività e la revoca in caso di cambio password che la RFC cita fra i possibili eventi di sicurezza.

import { firma, verifica } from './jwt.mjs';

const chiave = Buffer.from('0123456789abcdef0123456789abcdef');
const ACCESS_S = 900;          // durata dell'access token: 15 minuti (scelta dell'esempio)
const cfg = { iss: 'api.example', aud: 'app' };

// Modello in memoria. Gli identificatori sono contatori per rendere l'uscita riproducibile:
// in un sistema vero sarebbero valori casuali di almeno 128 bit.
class Autorizzazioni {
  #n = 0; #sessioni = new Set(); #famiglie = new Map(); #refresh = new Map();
  accesso(utente, t) { return firma({ sub: utente, ...cfg, exp: t + ACCESS_S }, chiave); }
  apriSessione() { const id = `sess-${++this.#n}`; this.#sessioni.add(id); return id; }
  sessioneValida(id) { return this.#sessioni.has(id); }
  chiudiSessione(id) { this.#sessioni.delete(id); }

  login(utente) {
    const famiglia = `fam-${++this.#n}`;
    this.#famiglie.set(famiglia, { utente, revocata: false });
    return this.#nuovoRefresh(famiglia);
  }
  #nuovoRefresh(famiglia) {
    const token = `refresh-${++this.#n}`;
    this.#refresh.set(token, { famiglia, usato: false });
    return token;
  }
  rinnova(token, t) {
    const r = this.#refresh.get(token);
    if (!r) return { errore: 'sconosciuto' };
    const f = this.#famiglie.get(r.famiglia);
    if (f.revocata) return { errore: 'famiglia revocata' };
    if (r.usato) { f.revocata = true; return { errore: 'riuso rilevato: famiglia revocata' }; }
    r.usato = true;
    return { accesso: this.accesso(f.utente, t), refresh: this.#nuovoRefresh(r.famiglia) };
  }
}

const a = new Autorizzazioni();
// A. Dopo il logout, che cosa vale ancora?
const sessione = a.apriSessione();
const jwt = a.accesso('ada', 0);
a.chiudiSessione(sessione);
const t = 100;
let jwtValido = true;
try { verifica(jwt, chiave, { ...cfg, adesso: t }); } catch { jwtValido = false; }
console.log(`logout a t=${t}s | sessione opaca: ${a.sessioneValida(sessione) ? 'valida' : 'rifiutata'} | JWT: ${jwtValido ? `ancora valido per ${ACCESS_S - t} s` : 'rifiutato'}`);

// B. Rotazione del refresh token con rilevamento del riuso.
const r1 = a.login('ada');
const r2 = a.rinnova(r1, 10).refresh;
console.log('utente rinnova con r1  ->', r2 ? 'nuovo refresh emesso' : 'errore');
console.log('ladro riusa r1         ->', a.rinnova(r1, 20).errore);
console.log('utente rinnova con r2  ->', a.rinnova(r2, 30).errore);

// Output:
// logout a t=100s | sessione opaca: rifiutata | JWT: ancora valido per 800 s
// utente rinnova con r1  -> nuovo refresh emesso
// ladro riusa r1         -> riuso rilevato: famiglia revocata
// utente rinnova con r2  -> famiglia revocata

L'uscita mostra la prima riga che abbiamo già commentato e poi la rotazione in tre mosse: l'utente rinnova con r1 e riceve un nuovo refresh; il ladro riusa r1 e il server rileva il riuso e revoca la famiglia; l'utente, con il suo r2 perfettamente valido fino a un attimo prima, trova la famiglia revocata. È il comportamento voluto: la difesa funziona accettando che il legittimo debba autenticarsi di nuovo. Resta il limite che l'access token già emesso, anche dopo la revoca, vale fino alla scadenza: con 15 minuti di vita, è la finestra di rischio che accetti.

OAuth 2.0: il codice di autorizzazione con PKCE

OAuth risolve un problema diverso: un'applicazione (il client) vuole accedere ai dati di un utente su un altro servizio senza avere la sua password. Il flusso raccomandato è il codice di autorizzazione: il client manda l'utente all'autorizzazione, l'utente consente, il client riceve un codice di breve durata e lo scambia con i token. Il punto fragile è il codice, che passa dal browser: se qualcuno lo intercetta, può scambiarlo al posto del client. PKCE (RFC 7636) lo chiude: il client inventa un segreto casuale, il verificatore, e manda all'autorizzazione solo il suo hash SHA-256 (la «challenge»); allo scambio presenta il verificatore, e chi ha rubato il solo codice non ce l'ha. La RFC 9700 richiede che i server di autorizzazione supportino PKCE, che confrontino gli indirizzi di ritorno per stringa esatta e sconsiglia ai client il flusso implicito.

Il server di prova sotto ha due percorsi, /authorize e /token, ed è un modello didattico: non emette token veri, non ha utenti e non apre alcuna porta (la prova usa inject()). Tre scelte nel codice corrispondono a regole del testo: il confronto dell'indirizzo di ritorno è un'uguaglianza esatta (RFC 9700, sezione 2.1); il codice si cancella a ogni tentativo, riuscito o no, perché la RFC 6749 (sezione 10.5) chiede codici brevi e monouso; e un verificatore presentato per un codice emesso senza challenge viene rifiutato, perché la RFC 9700 chiede di impedire il declassamento di PKCE.

import Fastify from 'fastify';
import { createHash } from 'node:crypto';

const s256 = (verificatore) => createHash('sha256').update(verificatore).digest('base64url');
const REDIRECT = 'https://app.example/callback';
const VERIFICATORE = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; // esempio dell'RFC 7636, appendice B
console.log('vettore RFC 7636 B:', s256(VERIFICATORE) === 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' ? 'challenge uguale' : 'DIVERSO');

const codici = new Map();
let n = 0;
const app = Fastify();
app.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' },
  (req, corpo, fatto) => fatto(null, Object.fromEntries(new URLSearchParams(corpo))));

app.get('/authorize', async (req, reply) => {
  const { redirect_uri, code_challenge, code_challenge_method } = req.query;
  if (redirect_uri !== REDIRECT) return reply.code(400).send({ error: 'invalid_request' }); // confronto esatto
  if (code_challenge && code_challenge_method !== 'S256') return reply.code(400).send({ error: 'invalid_request' });
  const code = `code-${++n}`;
  codici.set(code, { code_challenge });
  return reply.redirect(`${redirect_uri}?code=${code}`);
});

app.post('/token', async (req, reply) => {
  const { code, code_verifier, redirect_uri } = req.body;
  const voce = codici.get(code);
  codici.delete(code); // un codice vale una volta sola, anche se la richiesta fallisce
  const fallito = () => reply.code(400).send({ error: 'invalid_grant' });
  if (!voce || redirect_uri !== REDIRECT) return fallito();
  if (voce.code_challenge) {
    if (!code_verifier || s256(code_verifier) !== voce.code_challenge) return fallito();
  } else if (code_verifier) return fallito(); // anti-downgrade: verifier senza challenge registrata
  return { access_token: 'token-di-prova', token_type: 'Bearer' };
});

async function ottieniCodice(query) {
  const r = await app.inject({ url: '/authorize?' + new URLSearchParams(query) });
  return new URL(r.headers.location).searchParams.get('code');
}
async function scambia(campi) {
  const r = await app.inject({ method: 'POST', url: '/token', headers: { 'content-type': 'application/x-www-form-urlencoded' }, payload: new URLSearchParams({ redirect_uri: REDIRECT, ...campi }).toString() });
  return `${r.statusCode} ${r.json().error ?? 'token emesso'}`;
}
const conChallenge = { redirect_uri: REDIRECT, code_challenge: s256(VERIFICATORE), code_challenge_method: 'S256' };

console.log('client legittimo, verifier giusto      :', await scambia({ code: await ottieniCodice(conChallenge), code_verifier: VERIFICATORE }));
console.log('codice rubato, nessun verifier         :', await scambia({ code: await ottieniCodice(conChallenge) }));
console.log('codice rubato, verifier indovinato male:', await scambia({ code: await ottieniCodice(conChallenge), code_verifier: 'x'.repeat(43) }));
const rubato = await ottieniCodice(conChallenge);
await scambia({ code: rubato, code_verifier: VERIFICATORE });
console.log('stesso codice una seconda volta        :', await scambia({ code: rubato, code_verifier: VERIFICATORE }));
console.log('verifier su codice senza challenge     :', await scambia({ code: await ottieniCodice({ redirect_uri: REDIRECT }), code_verifier: VERIFICATORE }));
const r = await app.inject({ url: '/authorize?' + new URLSearchParams({ ...conChallenge, redirect_uri: 'https://app.example/callback/../evil' }) });
console.log('redirect_uri non identico              :', r.statusCode, r.json().error);

// Output:
// vettore RFC 7636 B: challenge uguale
// client legittimo, verifier giusto      : 200 token emesso
// codice rubato, nessun verifier         : 400 invalid_grant
// codice rubato, verifier indovinato male: 400 invalid_grant
// stesso codice una seconda volta        : 400 invalid_grant
// verifier su codice senza challenge     : 400 invalid_grant
// redirect_uri non identico              : 400 invalid_request

La prima riga è il controllo con l'appendice B della RFC 7636: lo stesso verificatore dà la stessa challenge. Il client legittimo ottiene il token. Chi ha rubato il codice, senza verificatore o con uno sbagliato, ottiene un 400 invalid_grant; lo stesso codice usato due volte fallisce la seconda; il verificatore su un codice senza challenge è rifiutato; un indirizzo di ritorno che non coincide esattamente con quello registrato dà invalid_request. Il modello non copre lo scambio reale (autenticazione del client, stato, scope): per questi punti rimando a API Security: OAuth 2.1, JWT e rate limiting. Una nota di stato: «OAuth 2.1» è, al 2026-11-13, una bozza dell'IETF (draft-ietf-oauth-v2-1, revisione 17 del 2026-10-31, documento del gruppo di lavoro), non uno standard; ciò che è pubblicato come raccomandazione è la RFC 9700.

Un ultimo pezzo, che qui non costruisco: se oltre a delegare l'accesso ti serve sapere chi è l'utente, si usa OpenID Connect, che la sua specifica definisce come «un semplice strato di identità sopra OAuth 2.0» e il cui ID token è un JWT. Il token d'accesso serve a chiamare un'API; l'ID token serve al client per sapere chi ha fatto il login. Confonderli, usando l'access token come prova di identità, è un errore tipico.

Che cosa sai fare adesso

Sai dire a quale costo ogni scelta compra la sua comodità: la sessione paga una lettura a richiesta e revoca subito, il JWT non legge nulla e non revoca senza stato, il refresh a rotazione sposta il controllo su un token raro. Sai scrivere un verificatore di JWT che sceglie l'algoritmo da sé e controlla scadenza, emittente e destinatario, e hai visto i quattro token che lo mettono alla prova. Sai spiegare perché PKCE rende inutile un codice di autorizzazione rubato, e perché «OAuth 2.1» non va citato come standard. Non ho trattato i cookie di sessione veri, con HttpOnly, Secure e SameSite: li trovi in Autenticazione sicura: session e cookie; né la custodia delle chiavi e le firme asimmetriche, in Errori crittografici: hashing, encryption e token. L'articolo precedente della serie è Sicurezza web, e il successivo, CI/CD multi-ambiente, chiude la serie.

Domande frequenti

Un JWT è cifrato?

Un JWT firmato non è cifrato: la firma protegge l'integrità, ma le dichiarazioni si leggono con una semplice decodifica base64url. Non metterci dati riservati. La cifratura è un'altra specifica (JWE) e non riguarda i token di questo articolo.

Come si revoca un JWT prima della scadenza?

Solo reintroducendo uno stato: un elenco di identificatori revocati consultato a ogni verifica, oppure access token molto brevi con un refresh token che il server controlla. Nella prova, dopo il logout, il JWT resta valido per 800 secondi su 900.

PKCE serve solo alle app pubbliche?

La RFC 9700 chiede ai server di autorizzazione di supportarlo in generale, e di impedirne il declassamento quando il client lo usa. Nel modello dell'articolo il server rifiuta il verificatore su un codice emesso senza challenge.

Tutti gli articoli sono raccolti nella pagina della serie.

Fonti

I listati sono stati eseguiti con Node 24.11.0, Fastify 5.12.8 (inject(), nessuna porta aperta) e jose 6.2.17, due volte con uscita identica. I modelli di sessione, rotazione e server di autorizzazione sono didattici, scritti per questo articolo: non emettono token reali e non sono da copiare in produzione. Nessun browser, provider di identità o servizio esterno è stato usato.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti