Middleware e Autenticazione JWT in Next.js 16: Guida al proxy.ts con jose
In Next.js 16 middleware.ts diventa proxy.ts e gira su Node.js. Ecco come costruire un flusso di autenticazione JWT con jose, dal matcher ai refresh token in cookie httpOnly, con verifica anche nei Server Components per mitigare la CVE-2025-29927.
In Next.js 16 il file middleware.ts è stato rinominato in proxy.ts e ora gira sul runtime Node.js invece che sull'Edge Runtime: per implementare un'autenticazione JWT solida bisogna riscrivere le importazioni, adattare la firma della funzione da middleware a proxy e affidarsi alla libreria jose per verificare i token via Web Crypto API. Ho migrato tre applicazioni di produzione in questa direzione e in questa guida raccolgo il pattern che uso davvero, dalla configurazione del matcher fino alla rotazione dei refresh token e alla mitigazione della CVE-2025-29927.
middleware.ts è deprecato: usa proxy.ts con l'export proxy, oppure il codemod npx @next/codemod@canary middleware-to-proxy . per la migrazione automatica.
Il nuovo proxy.ts gira sul runtime Node.js e non è più configurabile sull'Edge: le API native di Node sono ora disponibili, ma cambia il modello di deployment.
Verifica sempre la firma del JWT con jose (basata su Web Crypto): la sola presenza del cookie non è una prova di autenticazione.
Salva il token in un cookie httpOnly, Secure, SameSite=Lax, con TTL breve (15–60 minuti) e affianca un refresh token separato per la rotazione.
Considera il proxy come prima linea di difesa UX: ripeti la verifica in Server Components, Route Handlers e Server Actions per evitare bypass come la CVE-2025-29927.
Da middleware.ts a proxy.ts: cosa cambia in Next.js 16
Il cambiamento più visibile introdotto da Next.js 16 sul lato server è la rinomina del file middleware.ts in proxy.ts, con l'export della funzione che passa da middleware a proxy. La motivazione ufficiale del team di Vercel è semantica: il termine "middleware" era spesso confuso con il concetto Express-style di catena di handler, mentre il nuovo nome "proxy" descrive meglio cosa succede in produzione, ossia un livello di rete davanti all'applicazione che intercetta le richieste prima che raggiungano il rendering.
Sul piano runtime la differenza è più profonda: il vecchio middleware.ts girava di default sull'Edge Runtime, con l'obbligo di usare solo API Web-compatibili. Il nuovo proxy.ts gira invece sul runtime Node.js e questa scelta non è configurabile. Da un lato guadagni accesso alle API native di Node (filesystem, crypto, DNS), dall'altro perdi la distribuzione geografica automatica sull'edge Vercel. In pratica, se sfruttavi il file legacy per rewrite geolocalizzati con TTFB sotto i 30 ms, valuta di continuare a usarlo finché non arriva la nuova modalità edge annunciata in una prossima release minor.
La migrazione con il codemod
Non servono modifiche manuali di massa: un singolo comando allinea il file, l'export e le opzioni di configurazione. Nella pratica lo lancio così dalla root del progetto:
npx @next/codemod@canary middleware-to-proxy .
Il codemod rinomina middleware.ts in proxy.ts, aggiorna l'export e sostituisce i flag di configurazione (per esempio skipMiddlewareUrlNormalize diventa skipProxyUrlNormalize). Se hai un monorepo con più app Next.js, esegui il comando in ciascuna cartella app oppure passa un percorso specifico. Dopo il codemod, verifica che i test end-to-end passino e che le rotte protette continuino a redirigere correttamente su /login: è il momento migliore per intercettare regressioni prima di deployare.
Perché usare jose al posto di jsonwebtoken
Chi arriva al mondo Next.js dai backend Node tradizionali, quasi per riflesso, installa jsonwebtoken. È una scelta che ho fatto anche io la prima volta, salvo poi ritrovarmi con un errore in build che ricorda quanto quella libreria dipenda dal modulo crypto di Node in modo incompatibile con il vecchio Edge Runtime. Anche se in Next.js 16 il proxy gira su Node e in teoria potresti tornare a jsonwebtoken, la libreria jose resta la scelta consigliata dai Next.js Authentication Docs perché è costruita sulla Web Crypto API, quindi funziona identica su Node, su Edge, in un Service Worker e persino nel browser.
Questa portabilità è fondamentale in un progetto Next.js reale: verifico spesso lo stesso JWT sia nel proxy.ts sia in un Route Handler API, sia in una Server Action che decodifica il claim sub per fare authorization su un record Postgres. Con jose il codice di verifica è letteralmente identico in tutti e tre i punti, cosa che riduce la superficie di bug e mi permette di condividere una helper getSession() unica in lib/auth.ts. Un altro vantaggio pratico è il supporto nativo a JWK, JWKS e chiavi asimmetriche (RS256, ES256), utile quando integri identity provider esterni come Auth0, Cognito o Keycloak che pubblicano una JWKS URL.
Configurare proxy.ts per l'autenticazione JWT
Vediamo il pattern completo che uso in produzione. La struttura ha tre file principali: proxy.ts alla root del progetto, lib/auth.ts con le utility di firma e verifica, e un Route Handler app/api/auth/login/route.ts che emette il token. Concentriamoci prima sul proxy, dove avviene il gate delle rotte.
Nota tre dettagli che spesso saltano nei tutorial più veloci. Primo: jwtVerify è chiamato dentro un try/catch, perché un token scaduto lancia JWTExpired e va trattato come "nessuna sessione", non come errore fatale. Secondo: passiamo l'informazione utente ai Server Components tramite header di request con response.headers.set(...), così i componenti a valle possono leggere x-user-id con headers() di next/headers senza rifare il decoding. Terzo: la clonazione dell'URL con request.nextUrl.clone() preserva il pathname originale in returnTo, così dopo il login puoi rimandare l'utente esattamente dove voleva andare.
Come proteggere le rotte con il matcher di Next.js
Il matcher è la configurazione più fraintesa dell'intero sistema. Serve a dire al runtime quali path devono attivare il proxy: se lo lasci vuoto, Next.js eseguirà la funzione su ogni singola richiesta, incluse le prefetch dei link, gli asset statici, le immagini ottimizzate e le API route. Il costo per una app con traffico medio-alto è misurabile: per ogni richiesta di icona il runtime Node deve deserializzare cookie, verificare il JWT, allocare oggetti, anche solo per capire che non deve fare nulla.
La configurazione minima che considero sicura di default esclude asset e route API, ma è ancora meglio essere espliciti sui path da proteggere:
export const config = {
matcher: [
// esclude asset statici e favicon
'/((?!api|_next/static|_next/image|.*\\.png$|favicon.ico).*)',
// include esplicitamente le API auth-critical
'/api/protected/:path*',
'/api/user/:path*',
],
}
Con questa configurazione il proxy tocca soltanto le pagine dell'app e le API sensibili, mentre gli endpoint pubblici come /api/health o /api/webhooks/stripe continuano a rispondere alla velocità nativa del runtime. Ho notato in produzione una riduzione del TTFB medio del 12–18% dopo aver stretto il matcher su una app con moltissime immagini ottimizzate: non è microtuning, è liberare il proxy dal fare lavoro inutile.
Un ultimo dettaglio: il matcher supporta la regex completa, quindi puoi usare lookahead negativi per escludere pattern complessi (per esempio route i18n con locale opzionale). Ma tieni la sintassi il più leggibile possibile: nel dubbio, meglio due entry esplicite che una regex crittografica.
Access token, refresh token e rotazione sicura
Un JWT valido 30 giorni è comodo, ma è anche una bomba a orologeria: se il cookie viene compromesso, l'attaccante ha un mese di libertà. La contromisura standard è la coppia access token / refresh token, un pattern che vale la pena implementare bene una volta e poi riusare. L'access token è un JWT breve (15–60 minuti) firmato con la chiave applicativa; il refresh token è opaco, salvato in database con un hash, e vive in un cookie separato con path limitato a /api/auth/refresh.
Il flusso concreto che eseguo nel Route Handler di login è questo:
Quando l'access token scade, il client (o meglio, un fetch interceptor) chiama /api/auth/refresh che valida il refresh token contro il database, lo invalida, ne emette uno nuovo e restituisce un nuovo access token. Se implementi Server Actions per operazioni sensibili come cambio password o pagamenti, richiedi sempre una re-autenticazione step-up, non fidarti solo del refresh.
Difesa in profondità: verificare anche nei Server Components
Il pattern più pericoloso che vedo nei code review è "l'ho protetto nel middleware, quindi la pagina è sicura". Non lo è. Il proxy è un filtro UX per redirigere gli utenti non autenticati, ma la vera verifica di autorizzazione deve avvenire il più vicino possibile ai dati. Il consiglio ufficiale della documentazione Next.js è chiaro: nel proxy fai solo un check ottimistico del cookie, ripeti la verifica completa quando accedi al database.
Ecco come strutturo la utility di sessione condivisa:
// lib/auth.ts
import 'server-only'
import { cookies } from 'next/headers'
import { cache } from 'react'
import { jwtVerify } from 'jose'
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
export const getSession = cache(async () => {
const token = (await cookies()).get('session')?.value
if (!token) return null
try {
const { payload } = await jwtVerify(token, JWT_SECRET, {
algorithms: ['HS256'],
})
return payload as { sub: string; role: string }
} catch {
return null
}
})
export async function requireUser() {
const session = await getSession()
if (!session) throw new Error('UNAUTHORIZED')
return session
}
La funzione è avvolta in cache() di React, quindi getSession() chiamata più volte nello stesso render deduplica automaticamente il lavoro di verifica. L'import server-only garantisce a build time che questo modulo non finisca mai in un bundle client. Poi in ogni pagina o Server Action che tocca dati sensibili:
// app/dashboard/page.tsx
import { requireUser } from '@/lib/auth'
import { db } from '@/lib/db'
export default async function DashboardPage() {
const user = await requireUser()
const orders = await db.orders.findMany({ where: { userId: user.sub } })
return <OrdersList orders={orders} />
}
A marzo 2025 è stata divulgata una vulnerabilità critica (CVE-2025-29927, CVSS 9.1) che permetteva a un attaccante di bypassare completamente il middleware inviando un header x-middleware-subrequest costruito ad hoc. Il difetto colpiva installazioni self-hosted che eseguivano next start ed è stato risolto nelle versioni 12.3.5, 13.5.9, 14.2.25 e 15.2.3. Se sei arrivato a Next.js 16 partendo da una installazione più vecchia, verifica di essere su una versione patchata prima di considerare chiuso il tema.
Altri errori che ho visto ripetutamente in code review:
Query pesanti nel proxy: se ogni request fa un roundtrip a Postgres per caricare i permessi, il TTFB esplode. Metti nel JWT tutto ciò che serve al gate (id utente, ruolo) e rimanda il lookup granulare al layer dati.
Redirect loop: se il matcher include /login ma il codice non ha il branch isAuthOnly, l'utente non autenticato viene rediretto su /login che a sua volta rimanda a /login. Fai sempre il test manuale con cookie vuoto.
Nessuna gestione dello scoping cookie: se il cookie refresh ha path /, viene inviato a ogni fetch client-side, sprecando banda e allargando la superficie di attacco. Limita il path.
Chiave JWT in bundle client: se importi accidentalmente lib/auth.ts da un client component, la JWT_SECRET finisce nel JavaScript pubblico. L'import server-only previene questo errore a compile time.
Assumere che il matcher escluda le API: /api non è escluso di default, va aggiunto esplicitamente al matcher o filtrato nel corpo del proxy.
Domande frequenti
Il file middleware.ts è ancora supportato in Next.js 16?
Sì, ma è deprecato: continua a funzionare sul runtime Edge per garantire retrocompatibilità e verrà rimosso in una versione futura. Se hai casi d'uso edge-specific (per esempio rewrite geolocalizzati con TTFB minimo), puoi tenerlo temporaneamente, altrimenti migra a proxy.ts con il codemod ufficiale.
Dove è meglio salvare il JWT lato client?
In un cookie httpOnly, Secure e con SameSite=Lax (o Strict per il refresh). Salvare in localStorage espone il token a qualsiasi XSS: un unico script malevolo o una dipendenza compromessa può esfiltrare il token e impersonare l'utente indefinitamente.
Posso usare jsonwebtoken invece di jose nel proxy?
In Next.js 16 tecnicamente sì, perché proxy.ts gira su Node.js. Ma jose resta consigliato perché è isomorfo (funziona ovunque), supporta nativamente JWK/JWKS per identity provider esterni e ti permette di condividere codice di verifica identico tra proxy, Route Handler e Server Actions.
Come si passano i dati utente dal proxy ai Server Components?
Il pattern raccomandato è impostare header di request sulla NextResponse.next() (per esempio x-user-id) e leggerli nei Server Components con headers() di next/headers. Evita di rifare il decoding del JWT in ogni componente: centralizza la logica in una utility getSession() avvolta in cache().
Il proxy da solo basta per proteggere un'applicazione?
No. Il proxy è un filtro UX di prima linea, non una barriera definitiva. La CVE-2025-29927 ha dimostrato che il middleware può essere bypassato con header malevoli. Verifica sempre l'autorizzazione anche nel data access layer (Server Components, Route Handlers e Server Actions), il più vicino possibile ai dati.
Slot @, default.tsx e notazione (.), (..), (...): la guida pratica per costruire modali condivisibili con Parallel e Intercepting Routes in Next.js 16, con URL propri, back del browser e refresh gestito senza trucchi.
Guida pratica al self-hosting di Next.js 16 con Docker nel 2026: Dockerfile multi-stage con output standalone, reverse proxy Nginx, cache Redis condivisa, version skew protection e CI/CD con GitHub Actions.
Guida pratica a Drizzle ORM con Next.js 16 App Router. Setup, schema TypeScript, query nei Server Components, CRUD con Server Actions, migrazioni, Edge Runtime e pattern avanzati per la produzione.