Skip to content

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 versionImport fromBuilt on
5.0.0-beta (Auth.js)authio-provider-nextauth/v5The oidc provider type; reads its settings from the environment.
^4.24.0authio-provider-nextauthThe oauth provider type with discovery; every setting is passed in code.
Eitherauthio-provider-nextauth/popupThe 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.

Auth.js v5Bash
npm install authio-provider-nextauth next-auth@5.0.0-beta.32

Environment variables

VariableWhat it holds
AUTH_AUTHIO_ISSUERThe realm’s issuer, such as https://authio.api.hyperio.tech/realms/acme.
AUTH_AUTHIO_IDThe client ID, such as acme-web.
AUTH_AUTHIO_SECRETThe client secret. Leave it out for a public client.
AUTH_SECRETThe key that encrypts the session cookie, from npx auth secret. NEXTAUTH_SECRET on v4.
AUTH_URLYour 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

On v5, set 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.

auth.tsTypeScript
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.

lib/auth.tsTypeScript
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.

ScopeWhat it adds
openidThe ID token. Required unless the client turns Require OpenID Connect off.
profile, email, phoneNames and username; the address and whether it is verified; the phone number.
rolesThe user’s roles in the access token, for your API to check — see Roles and permissions.
offline_accessA 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:

auth.tsTypeScript
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.

ClaimsWhenWhat they say
subAlwaysThe user’s stable ID in the realm.
name, given_name, family_name, preferred_username, email, email_verifiedprofile and email scopesWho the person is.
sid, auth_time, acr, amrAlwaysThe session, when the person authenticated, how strongly (1 to 3) and with which methods (pwd, otp, swk, fed, mfa…).
idp, org_id, org_aliasFederated or organization sign-inThe 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:

types/next-auth.d.tsTypeScript
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
  }
}
What the 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.

auth.tsTypeScript
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:

auth.tsTypeScript
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_token only 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_id in the body instead of the Authorization header.

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.

proxy.tsTypeScript
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.

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.

app/auth/popup/page.tsxTypeScript
'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
}
  • signInWithPopup never rejects: it resolves { ok: true } or { ok: false, error } with popup_blocked (call it straight from the click), popup_closed, timeout (5 minutes) or the Auth.js error.
  • The pages default to /auth/popup and /auth/popup/done on the button’s origin; change them with signInWithPopup({ startPath }) and startPopupSignIn(signIn, { donePath }).
  • A refused sign-in lands on Auth.js’s error page: point pages.error at a page that calls completePopupSignIn() and the button receives the error.
  • Do not serve the page with Cross-Origin-Opener-Policy: same-origin; use same-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 seeWhyWhat to do
No refresh_token in the responseoffline_access was not granted.Allow it on the client and add it to the full scope string.
UntrustedHost in productionNEXTAUTH_URL set instead of AUTH_URL on v5.Set AUTH_URL to the app’s public address.
Please define a secretAUTH_SECRET is empty.Generate one with npx auth secret.
Type errors importing the root entry in a v5 appThe v4 entry imports next-auth/providers/oauth, which v5 lacks.Import from authio-provider-nextauth/v5.

Next steps