Next.js
Use authio-provider-nextauth in production: Auth.js v5 or NextAuth v4, scopes, typed claims, refresh tokens, protected routes and sign-in in a popup.
authio-provider-nextauth is the Authio provider for Auth.js v5 and NextAuth v4, version 2.1.0 on npm. It is built on each version’s standard OpenID Connect provider, so discovery, PKCE and ID token validation come from Auth.js itself; the package adds Authio’s defaults, the typed claims and the popup helpers.
This page picks up where the Quickstart ends: the right entry point, more scopes, longer sessions and routes only signed-in people reach.
Pick an entry point
| Your next-auth version | Import from | Built on |
|---|---|---|
5.0.0-beta (Auth.js) | authio-provider-nextauth/v5 | The oidc provider type; reads its settings from the environment. |
^4.24.0 | authio-provider-nextauth | The oauth provider type with discovery; every setting is passed in code. |
| Either | authio-provider-nextauth/popup | The popup sign-in helpers, which import nothing from next-auth. |
Both majors export the same Authio provider and AuthioProfile type. next-auth is a peer dependency: install the version you use, and pin the Auth.js beta rather than tracking the beta tag, which moves between breaking releases.
npm install authio-provider-nextauth next-auth@5.0.0-beta.32Environment variables
| Variable | What it holds |
|---|---|
AUTH_AUTHIO_ISSUER | The realm’s issuer, such as https://authio.api.hyperio.tech/realms/acme. |
AUTH_AUTHIO_ID | The client ID, such as acme-web. |
AUTH_AUTHIO_SECRET | The client secret. Leave it out for a public client. |
AUTH_SECRET | The key that encrypts the session cookie, from npx auth secret. NEXTAUTH_SECRET on v4. |
AUTH_URL | Your app’s public address, such as https://app.example.com. NEXTAUTH_URL on v4. |
Auth.js v5 reads the three AUTH_AUTHIO_ variables by itself: it upper-cases the provider ID, authio, and looks up exactly these names. On v4, pass them to Authio() in code.
Two variables people mix up
AUTH_URL, not NEXTAUTH_URL: only AUTH_URL (or AUTH_TRUST_HOST=true) marks the host as trusted, and without it a self-hosted deploy fails with UntrustedHost. And AUTH_SECRET is never the client secret: share one value and rotating the client secret signs out every open session.Configure Auth.js v5
With the variables in place the provider needs no credentials in code. Add the state check: the v5 entry point sets no checks, so Auth.js falls back to PKCE alone, and state ties the callback to the request that started it. Options you pass are merged over the environment.
import NextAuth from 'next-auth'
import Authio from 'authio-provider-nextauth/v5'
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [Authio({ checks: ['pkce', 'state'] })],
})The redirect URI to register on the client is https://app.example.com/api/auth/callback/authio, one per environment.
Configure NextAuth v4
On v4, issuer, clientId and clientSecret are passed explicitly; issuer is required, because the discovery URL is built from it. The entry point already asks for openid email profile, turns on the pkce and state checks, and maps sub, name (or preferred_username), email and picture onto the user.
import type { NextAuthOptions } from 'next-auth'
import { Authio } from 'authio-provider-nextauth'
export const authOptions: NextAuthOptions = {
providers: [
Authio({
issuer: process.env.AUTH_AUTHIO_ISSUER!,
clientId: process.env.AUTH_AUTHIO_ID!,
clientSecret: process.env.AUTH_AUTHIO_SECRET!,
}),
],
}Scopes
Both entry points ask for openid profile email. Every scope you request must also be allowed on the client in Clients.
| Scope | What it adds |
|---|---|
openid | The ID token. Required unless the client turns Require OpenID Connect off. |
profile, email, phone | Names and username; the address and whether it is verified; the phone number. |
roles | The user’s roles in the access token, for your API to check — see Roles and permissions. |
offline_access | A refresh token — see Refresh tokens. |
Override authorization.params.scope to change the list. Options are merged key by key, but scope is one string and is replaced whole, so write the complete list:
Authio({
checks: ['pkce', 'state'],
authorization: {
params: { scope: 'openid profile email roles offline_access' },
},
})To call an API of yours registered as a resource server, add its identifier, such as https://api.example.com, as resource in the same params. The access token then carries it as aud instead of the default Authio, and refreshes keep it.
Typed claims
AuthioProfile types the claims of the ID token Auth.js validated. The profile argument of the jwt callback holds them on sign-in only, so copy what you need into the token there.
| Claims | When | What they say |
|---|---|---|
sub | Always | The user’s stable ID in the realm. |
name, given_name, family_name, preferred_username, email, email_verified | profile and email scopes | Who the person is. |
sid, auth_time, acr, amr | Always | The session, when the person authenticated, how strongly (1 to 3) and with which methods (pwd, otp, swk, fed, mfa…). |
idp, org_id, org_alias | Federated or organization sign-in | The external provider’s alias; the organization of the session. |
Claims added by your client’s claim mappers arrive too, typed unknown by the index signature. Declare what you keep on the token and the session once:
import type { DefaultSession } from 'next-auth'
import 'next-auth/jwt'
declare module 'next-auth' {
interface Session {
error?: string
user: { username?: string } & DefaultSession['user']
}
}
declare module 'next-auth/jwt' {
interface JWT {
username?: string
accessToken?: string
expiresAt?: number
refreshToken?: string
error?: string
}
}session callback returns is served to the browser by /api/auth/session. Put the access token there only if browser code must call your API with it.Confidential and public clients
A Next.js app exchanges the code on its server, so a confidential client is the usual choice. If the client is public (method none), say so: otherwise Auth.js still sends a Basic header with the literal password undefined, which Authio ignores for a public client — until the client is made confidential. On v4, also pass clientSecret: '' to satisfy the types.
Authio({
checks: ['pkce', 'state'],
client: { token_endpoint_auth_method: 'none' },
})Refresh tokens
Authio issues a refresh_token only when the granted scopes include offline_access — no lifetime setting produces one. Allow the scope on the client, request it, and renew the access token yourself in the jwt callback:
import NextAuth from 'next-auth'
import Authio from 'authio-provider-nextauth/v5'
async function refresh(refreshToken: string) {
const credentials = btoa(`${process.env.AUTH_AUTHIO_ID}:${process.env.AUTH_AUTHIO_SECRET}`)
const response = await fetch(`${process.env.AUTH_AUTHIO_ISSUER}/protocol/openid-connect/token`, {
method: 'POST',
headers: {
authorization: `Basic ${credentials}`,
'content-type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken }),
})
if (!response.ok) return null
return (await response.json()) as {
access_token: string
expires_in: number
refresh_token?: string
}
}
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Authio({
checks: ['pkce', 'state'],
authorization: { params: { scope: 'openid profile email offline_access' } },
}),
],
callbacks: {
async jwt({ token, account }) {
if (account) {
return {
...token,
accessToken: account.access_token,
expiresAt: account.expires_at,
refreshToken: account.refresh_token,
}
}
if (token.expiresAt && Date.now() < (token.expiresAt - 60) * 1000) return token
const refreshed = token.refreshToken ? await refresh(token.refreshToken) : null
if (!refreshed) return { ...token, error: 'RefreshTokenError' }
return {
...token,
accessToken: refreshed.access_token,
expiresAt: Math.floor(Date.now() / 1000) + refreshed.expires_in,
refreshToken: refreshed.refresh_token ?? token.refreshToken,
error: undefined,
}
},
async session({ session, token }) {
session.error = token.error
return session
},
},
})- Keep the new refresh token. Refresh tokens rotate by default; the old one is spent. Within the reuse window (10 seconds by default) a repeated refresh gets the same replacement; a spent token presented later ends the session as a sign of theft.
- No new ID token. A refresh returns an
id_tokenonly if the client turns on ID Token on Refresh. - Lifetimes are per client. The refresh token lasts 60 minutes by default.
- Public clients send
client_idin the body instead of theAuthorizationheader.
When session.error is RefreshTokenError, start the sign-in again: if the person’s Authio session is still alive, they go straight through.
Protect routes
Wrap auth in the proxy to send anyone without a session to sign in before a page renders. In Next.js 16 the file is proxy.ts; on Next.js 15 and earlier it is middleware.ts and the export is named middleware.
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
export const proxy = auth((req) => {
if (!req.auth) {
const signInUrl = new URL('/api/auth/signin', req.nextUrl.origin)
signInUrl.searchParams.set('callbackUrl', req.nextUrl.href)
return NextResponse.redirect(signInUrl)
}
})
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
}The proxy decides what renders, not what data may be read: call auth() again in server components, server actions and route handlers that return protected data, and let your API verify the access token itself — see .NET or token validation.
Sign in in a popup
authio-provider-nextauth/popup keeps the page where it is and runs the sign-in in a popup over it. The ordinary flow runs inside: Auth.js exchanges the code on your server, the session cookie lands on your origin, and Authio receives display=popup and fits its screens to the window. It needs two small pages and no extra redirect URI.
'use client'
import { useEffect } from 'react'
import { signIn } from 'next-auth/react'
import { startPopupSignIn } from 'authio-provider-nextauth/popup'
export default function PopupStart() {
useEffect(() => {
startPopupSignIn(signIn)
}, [])
return null
}signInWithPopupnever rejects: it resolves{ ok: true }or{ ok: false, error }withpopup_blocked(call it straight from the click),popup_closed,timeout(5 minutes) or the Auth.js error.- The pages default to
/auth/popupand/auth/popup/doneon the button’s origin; change them withsignInWithPopup({ startPath })andstartPopupSignIn(signIn, { donePath }). - A refused sign-in lands on Auth.js’s error page: point
pages.errorat a page that callscompletePopupSignIn()and the button receives the error. - Do not serve the page with
Cross-Origin-Opener-Policy: same-origin; usesame-origin-allow-popups.
The package does not use response_mode=web_message: the popup comes back through /api/auth/callback/authio like any sign-in, and your own done page tells the button. Without Auth.js, see the popup flow on the wire.
Troubleshooting
| What you see | Why | What to do |
|---|---|---|
No refresh_token in the response | offline_access was not granted. | Allow it on the client and add it to the full scope string. |
UntrustedHost in production | NEXTAUTH_URL set instead of AUTH_URL on v5. | Set AUTH_URL to the app’s public address. |
Please define a secret | AUTH_SECRET is empty. | Generate one with npx auth secret. |
| Type errors importing the root entry in a v5 app | The v4 entry imports next-auth/providers/oauth, which v5 lacks. | Import from authio-provider-nextauth/v5. |