@hyper.authio/provider-nextauth
v5.1.0
Published
Authio for NextAuth.js v4 and Auth.js v5, configured like the client in the console through NextAuth's own options (checks, client authentication incl. private_key_jwt, ID token algorithm incl. HS*): sign-in validated by @hyper.authio/client, token refres
Maintainers
Readme
Authio for NextAuth.js / Auth.js
Sign people in to a Next.js app with Authio, on
NextAuth.js v4 or Auth.js v5, and keep the session healthy
afterwards: the protocol work is done by @hyper.authio/client,
the same client every Authio library for Node is built on.
- Configured like the client in the console, in the places NextAuth itself reads:
checks,client(token_endpoint_auth_methodwithclient_secret_basic,client_secret_post,private_key_jwtornone,id_token_signed_response_algincluding HS256/384/512) and the providerid. The package imposes none of it: left out, NextAuth's default applies, and Authio refuses what does not match the client. - Sign-in with the
issparameter checked (RFC 9207) and the ID token validated by the client: signature with the client's algorithm,iss,aud,exp, payloadtyp=ID,azp,at_hash, andnonceandmax_agewhen asked for. - Encrypted tokens (nested JWE) with
RSA-OAEP-256(Authio's default) andRSA-OAEP, on both majors. - Refresh inside the package: the
jwtcallback renews the access token before it expires, stores the rotated refresh token, shares one refresh among concurrent requests, and reportsRefreshAccessTokenErroron the session. - Server-side sessions, optional: the tokens in Redis (or any shared store), sealed with a key only the cookie holds, and a reference in one small cookie; one renewal per session across every instance, and a sign-out that ends the session on the server.
- Roles, permissions and claims on the session, typed: roles, permissions, scopes, organization, resource access,
caller kind, sign-in level; with
checkAccess,hasRole,hasPermissionandhasScopefor pages, route handlers and the middleware. Read from the access token, or from Authio's introspection for lightweight and reference access tokens. - Logout: end-session at Authio with
id_token_hintandpost_logout_redirect_uri, refresh-token revocation, and back-channel and front-channel logout endpoints that end the session when Authio ends it. - Step-up: turns an API's
insufficient_user_authenticationchallenge into the parameters of a new sign-in. - Popup sign-in, on both majors.
Upgrading from 4.x? In 5.0.0
getAccessTokenis asynchronous (await authio.getAccessToken(token)), in both modes;token.authiois typedAuthioTokenState | AuthioSessionReference; and a Redis client forsessionStoreneedseval. SeeCHANGELOG.md.
Contents
- Entry points
- Install
- Set up the Authio client
- NextAuth v4
- Auth.js v5
- Mirroring the client
- Options
- What the sign-in checks
- Encrypted tokens
- The session
- Refresh
- Lightweight and reference access tokens
- Server-side sessions
- Roles and permissions
- Calling an API
- Logout
- Back-channel logout
- Front-channel logout
- Step-up
- Popup sign-in
- Examples
- Troubleshooting
- Contributing
Entry points
| Your next-auth | Import from | Built on |
| --- | --- | --- |
| ^4.24.0 | @hyper.authio/provider-nextauth | type: "oauth", OIDC discovery, the code exchange done by @hyper.authio/client |
| ^5.0.0-beta.25 (Auth.js) | @hyper.authio/provider-nextauth/v5 | type: "oidc", the ID token validated by @hyper.authio/client |
| either | @hyper.authio/provider-nextauth/popup | nothing from next-auth |
| either | @hyper.authio/provider-nextauth/redis | nothing from next-auth; an ioredis-compatible client you create |
Both majors export the same names: createAuthio, Authio, checkAccess, hasRole, hasPermission, hasScope,
getAuthioClaims, createMemoryLogoutStore, toAuthioSessionClaims, AuthioSessionStoreUnavailableError and the
types.
Install
# NextAuth v4
npm install @hyper.authio/provider-nextauth next-auth@^4.24.0
# Auth.js v5: pin the beta, it breaks between beta numbers
npm install @hyper.authio/provider-nextauth [email protected]Node.js 20.19 or later.
Set up the Authio client
In the Authio console, on the realm people sign in to (its issuer is https://{authio}/realms/{realm}):
- Create a client with client authentication on (a Next.js app redeems the code on its server). Whatever token
endpoint method it has, name the same one in
client.token_endpoint_auth_method(see Mirroring the client). - Redirect URI:
https://your-app.example.com/api/auth/callback/authio(/callback/{id}with anidof your own), one per environment. - Post-logout redirect URI: where Authio sends the browser after signing out, such as
https://your-app.example.com/. - Grant types
authorization_codeandrefresh_token; PKCE on withS256, sent withchecks: ["pkce", …]. - Scopes
openid,profile,email, plusroles(roles and permissions in the access token) andoffline_access(Authio issues a refresh token only withoffline_access). - Access token format
Jwt, so roles can be read from it (aReferencetoken is opaque). - For back-channel logout: back-channel logout URI
https://your-app.example.com/api/auth/backchannel-logout. For front-channel logout: front-channel logout URIhttps://your-app.example.com/api/auth/frontchannel-logout, with session required.
NextAuth v4
AUTH_AUTHIO_ISSUER="https://auth.example.com/realms/acme"
AUTH_AUTHIO_ID="storefront"
AUTH_AUTHIO_SECRET="..."
NEXTAUTH_SECRET="..." # openssl rand -base64 32; never the client secret
NEXTAUTH_URL="https://your-app.example.com"// lib/auth.ts
import type { NextAuthOptions } from "next-auth";
import { createAuthio } from "@hyper.authio/provider-nextauth";
export const authio = createAuthio({
issuer: process.env.AUTH_AUTHIO_ISSUER!,
clientId: process.env.AUTH_AUTHIO_ID!,
clientSecret: process.env.AUTH_AUTHIO_SECRET!,
checks: ["pkce", "state", "nonce"],
authorization: { params: { scope: "openid profile email roles offline_access" } },
postLogoutRedirectUri: `${process.env.NEXTAUTH_URL}/`,
});
export const authOptions: NextAuthOptions = {
providers: [authio.provider],
callbacks: authio.callbacks,
events: authio.events,
};// pages/api/auth/[...nextauth].ts
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
export default NextAuth(authOptions);v4 does not infer credentials from the environment, so issuer and clientId are required, and clientSecret
for a client that authenticates with a secret.
Auth.js v5
AUTH_AUTHIO_ISSUER="https://auth.example.com/realms/acme"
AUTH_AUTHIO_ID="storefront"
AUTH_AUTHIO_SECRET="..."
AUTH_SECRET="..." # npx auth secret; never the client secret
AUTH_URL="https://your-app.example.com"// auth.ts
import NextAuth from "next-auth";
import { createAuthio } from "@hyper.authio/provider-nextauth/v5";
export const authio = createAuthio({
checks: ["pkce", "state", "nonce"],
authorization: { params: { scope: "openid profile email roles offline_access" } },
postLogoutRedirectUri: new URL("/", process.env.AUTH_URL).toString(),
});
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [authio.provider],
callbacks: authio.callbacks,
events: authio.events,
});// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;Every option is optional on v5: issuer, clientId, clientSecret and clientEncryptionKey default to
AUTH_AUTHIO_ISSUER, AUTH_AUTHIO_ID, AUTH_AUTHIO_SECRET and AUTH_AUTHIO_ENCRYPTION_KEY. The prefix is not
yours to rename: Auth.js derives it from the provider id, authio; with an id of your own, pass issuer,
clientId and clientSecret explicitly. Use AUTH_URL rather than NEXTAUTH_URL: only
AUTH_URL (or AUTH_TRUST_HOST=true) also marks the host as trusted, without which a self-hosted deploy fails with
UntrustedHost.
Your own callbacks
authio.callbacks are plain functions: call them from yours to add fields of your own.
callbacks: {
...authio.callbacks,
async jwt(params) {
const token = await authio.callbacks.jwt(params);
if (token && params.profile) token.username = params.profile.preferred_username as string;
return token;
},
async session(params) {
const session = await authio.callbacks.session(params);
session.user.username = params.token.username;
return session;
},
},Authio(options) is still exported: the provider alone, with the same sign-in validation, for an app that needs no
refresh, claims or logout.
Mirroring the client
The package does not decide anything the client's registration decides. Each setting of the console goes where NextAuth (v4) or Auth.js (v5) reads it, and a setting left out is off, or NextAuth's own default, exactly as with any other provider. Authio refuses a request that does not match the client and accepts one that sends more than it requires (PKCE to a client that does not require it).
| In the Authio console | NextAuth v4 | Auth.js v5 | Left out |
| --- | --- | --- | --- |
| PKCE on | checks: ["pkce", …] | checks: ["pkce", …] | v4: no PKCE (checks defaults to ["state"]); v5: PKCE (["pkce"]) |
| PKCE methods | S256 always: NextAuth sends nothing else, so there is no option | same | S256: keep it allowed |
| state / nonce (not required by Authio) | checks: [..., "state", "nonce"] | checks: [..., "state", "nonce"] | v4: state only; v5: neither |
| Client authentication off | client: { token_endpoint_auth_method: "none" }, no clientSecret | same | |
| Token endpoint method client_secret_basic | nothing, or client.token_endpoint_auth_method: "client_secret_basic" | same | client_secret_basic (NextAuth's default) |
| Token endpoint method client_secret_post | client.token_endpoint_auth_method: "client_secret_post" | same | |
| Token endpoint method private_key_jwt + JWKS | client: { token_endpoint_auth_method: "private_key_jwt", token_endpoint_auth_signing_alg: "RS256" }, jwks: { keys: [privateJwk] } | client.token_endpoint_auth_method: "private_key_jwt", token: { clientPrivateKey: cryptoKey } (or { key, kid }) | |
| Signing algorithm RS/PS/ES* | client.id_token_signed_response_alg (optional) | same | the asymmetric algorithms the discovery document announces |
| Signing algorithm HS256/384/512 | client.id_token_signed_response_alg: "HS256", verified with clientSecret (at least 32, 48 or 64 bytes, RFC 7518 §3.2) | same | refused (invalid_algorithm) |
| Signed UserInfo | client.userinfo_signed_response_alg | same | id_token_signed_response_alg |
| Encryption with the client's own key | clientEncryptionKey | clientEncryptionKey | nothing decrypted |
| Encryption algorithm (JWE alg) | client.id_token_encrypted_response_alg (or userinfo_encrypted_response_alg) | encryptionAlgorithms (Auth.js has no field for it) | RSA-OAEP and RSA-OAEP-256 |
| Encryption method (JWE enc), CBC-HS or AES-GCM | client.id_token_encrypted_response_enc (or userinfo_encrypted_response_enc) | encryptionMethods (Auth.js has no field for it) | the three CBC-HS methods; AES-GCM is refused |
| Redirect URI | the provider id and NextAuth's base path: {NEXTAUTH_URL}/api/auth/callback/{id} | {AUTH_URL}{basePath}/callback/{id} | id = authio |
| Post-logout redirect URI | postLogoutRedirectUri | same | Authio stays on its signed-out screen |
| Resource server (RFC 8707) | authorization.params.resource, repeated when the code is redeemed and on every refresh (tokens.refresh({ resource })) | same | |
| Maximum authentication age | authorization.params.max_age or client.default_max_age, checked against auth_time | client.default_max_age (checked by Auth.js) | not checked |
| Back-channel logout URI | authio.backchannelLogout / backchannelLogoutApiRoute | authio.backchannelLogout | |
| Front-channel logout URI | authio.frontchannelLogout / frontchannelLogoutApiRoute | authio.frontchannelLogout | |
The same method is used to redeem the code, refresh, revoke and introspect: on v4 everything goes through
@hyper.authio/client, and on v5 Auth.js redeems the code with client.token_endpoint_auth_method while the package
hands the same value to @hyper.authio/client for the rest.
A max_age passed per sign-in (signIn("authio", {}, { max_age }), step-up) is enforced by Authio; on v4 the package
checks auth_time only against the configured one, because NextAuth keeps no record of per-request parameters.
Options
Names follow the option names shared by every Authio library (conformance/options.md).
| Option | Default | |
| --- | --- | --- |
| issuer | v5: AUTH_AUTHIO_ISSUER | The realm's issuer. Required on v4. |
| clientId, clientSecret | v5: AUTH_AUTHIO_ID, AUTH_AUTHIO_SECRET | clientId is required on v4; clientSecret for client_secret_basic, client_secret_post and HS* ID tokens. |
| clientEncryptionKey | AUTH_AUTHIO_ENCRYPTION_KEY | The private half of the client's own encryption key: JWK (object or JSON), PEM (PKCS#8 or PKCS#1), or a function returning it. See Encrypted tokens. |
| allowLegacyEncryptionAlgorithms | false | Accepted for parity; RSA1_5 stays refused even when true (Node cannot decrypt it). |
| encryptionAlgorithms | v5: RSA-OAEP, RSA-OAEP-256 | v5 only: the JWE alg values decrypted, as registered for the client. On v4, client.id_token_encrypted_response_alg. |
| encryptionMethods | v5: the three CBC-HS methods | v5 only: the JWE enc values decrypted (A128CBC-HS256, A192CBC-HS384, A256CBC-HS512, A128GCM, A192GCM, A256GCM), as registered for the client. On v4, client.id_token_encrypted_response_enc. |
| requireHttps | true | Refuses an http issuer except on loopback. |
| tokenRefreshSkew | 60 | Seconds before expiry the access token is refreshed (at most half its lifetime). |
| postLogoutRedirectUri | | Default post_logout_redirect_uri of endSessionUrl. |
| logoutStore | in memory | Where back-channel logouts wait for the JWT sessions they end. |
| sessionStore | | Keeps the tokens on the server and a reference in the cookie. See Server-side sessions. |
| sessionStoreCache | off | true or { maxEntries } (1000): keeps opened records in memory until their refreshAt. Renewal never uses it. |
| sessionMaxAge | 30 days | NextAuth's session.maxAge, in seconds: a record lives this long plus an hour after each write. |
| accessTokenClaims | "token" | Where the session's claims come from: the access token, or "introspection" for a client whose access tokens are Lightweight. A Reference token is introspected in either mode; without the option, active: false only leaves its claims out. See Lightweight and reference access tokens. |
| extraClaims | | Access token claims copied as they are to claims.extra, on sign-in and on every refresh, in both modes; read where accessTokenClaims reads the others. |
| onBackchannelLogout | | Called with each validated logout token (subject, sessionId, tokenId, claims). |
| onFrontchannelLogout | | Called with each checked front-channel logout (sessionId, issuer). |
| fetch, clock | global fetch, system clock | For tests. |
The provider's own options (id, checks, client, authorization, profile, name, style, and jwks on v4 or
token.clientPrivateKey on v5) are NextAuth's, with NextAuth's defaults, and merged over the provider's (scope is a
string and is replaced whole: spell out every scope). On v4 token, idToken and wellKnown are the package's.
What the sign-in checks
| | NextAuth v4 | Auth.js v5 |
| --- | --- | --- |
| The checks you configured (PKCE S256, state, nonce) generated and kept in cookies | NextAuth | Auth.js |
| state compared (with the state check), iss checked (RFC 9207), code redeemed (with the verifier of the pkce check) | @hyper.authio/client (handleAuthorizationCallback) | Auth.js (oauth4webapi) |
| ID token decrypted (JWE), signature against the realm's JWKS, iss, aud, exp, typ = ID, azp, at_hash | @hyper.authio/client | @hyper.authio/client (on the token response, before Auth.js reads it) |
| nonce of the ID token compared with the cookie's (with the nonce check) | @hyper.authio/client | Auth.js (oauth4webapi) |
| auth_time against max_age | @hyper.authio/client (authorization.params.max_age, client.default_max_age) | Auth.js (client.default_max_age) |
A refusal ends the sign-in on NextAuth's error page (OAuthCallback on v4, CallbackRouteError / Configuration on
v5); the logged cause names the reason (AuthioTokenValidationError: The ID token's nonce is not the one sent., …).
Encrypted tokens
When Authio encrypts the client's tokens to the client's own key pair (Security → Encryption key → Client's own key), hand the package the private key Authio showed when it generated the pair:
# The private JWK as JSON, or a PEM private key; a PEM's line breaks may be written as \n.
AUTH_AUTHIO_ENCRYPTION_KEY='{"kty":"RSA","n":"...","e":"AQAB","d":"...","p":"...","q":"...","dp":"...","dq":"...","qi":"..."}'ID tokens, access tokens (read for the session's claims), logout tokens and UserInfo are decrypted with it, on both
majors. Without other configuration, RSA-OAEP-256 (Authio's default) and RSA-OAEP are accepted, with
A128CBC-HS256, A192CBC-HS384 or A256CBC-HS512. A client registered with AES-GCM (A128GCM, A192GCM,
A256GCM) must say so, and any list replaces the defaults. Authio encrypts every token of a client with the same pair,
so one setting covers the ID token, the access token, UserInfo and logout tokens.
On v4, with openid-client's own fields:
client: { id_token_encrypted_response_alg: "RSA-OAEP-256", id_token_encrypted_response_enc: "A128GCM" },On v5, where Auth.js has no such field, with the names @hyper.authio/client uses:
createAuthio({ encryptionAlgorithms: ["RSA-OAEP-256"], encryptionMethods: ["A128GCM"] });A token encrypted with a method that is not listed is refused, and the logged cause says which: The token's content
is encrypted with 'A128GCM', and encryptionMethods accepts A128CBC-HS256 or A192CBC-HS384 or A256CBC-HS512.
RSA1_5 is not supported. Node and Web Crypto cannot decrypt PKCS#1 v1.5, so a token encrypted with it is refused
(legacy_algorithm) even with allowLegacyEncryptionAlgorithms. Move such a client to RSA-OAEP-256.
The session keeps the ID token decrypted (a signed JWT, which is what id_token_hint needs) and the access token as
Authio issued it, which is what your API expects. authio.decrypt(token) opens one when you need its claims.
The session
The package augments NextAuth's types; nothing to declare yourself.
session.authio?.subject; // the person's Authio id (sub)
session.authio?.roles; // ["billing-admin"]
session.authio?.permissions; // { invoice: ["read", "write"] }
session.authio?.scopes; // ["openid", "profile", "roles", "offline_access"]
session.authio?.organizationAlias; // "acme-europe"
session.authio?.authenticationLevel; // 1, 2 or 3 (acr)
session.error; // "RefreshAccessTokenError" | "SessionEndedError" | undefinedsession.authio (AuthioSessionClaims) is read from the access token (or from its introspection, see
Lightweight and reference access tokens), merged over the ID token's claims,
with the rules every Authio library follows (conformance/callers.json, conformance/resource-access.json):
| Field | From |
| --- | --- |
| subject, clientId, sessionId | sub, azp (else client_id), sid |
| kind | user for a person; client, consumer, account_credential for machines |
| scopes | scope |
| roles, permissions, resourceAccess | the seven *_resource_access claims, merged, case-insensitive, malformed claims ignored |
| groups | groups (emitted through a claim mapper) |
| organizationId, organizationAlias, accountId | org_id, org_alias, account_id |
| acr, authenticationLevel, authenticationMethods, authTime, identityProvider | acr, acr as a number, amr, auth_time, idp |
| impersonated, impersonator, delegated, actorChain, delegationId | act, delegation_id |
Fields that do not apply are left out. With extraClaims: ["subscription"], session.authio.extra holds those claims of
the access token as they are ({} when the token has none of them), so the app does not have to open the access token.
The tokens stay in the encrypted cookie, under token.authio (accessToken, refreshToken, idToken, expiresAt,
refreshAt, signedInAt, claims), or with a sessionStore in the store, the cookie keeping a reference (see
Server-side sessions): /api/auth/session never serves them to the browser. token.authio is
typed AuthioTokenState | AuthioSessionReference, one per mode; tell them apart with "ref" in token.authio, and read
the access token with await authio.getAccessToken(token), which works in both.
These callbacks work with the JWT session strategy (the default without an adapter). With a database session the
jwt callback does not run: the tokens are in the Account row, session.authio is not filled, and refresh is up to
the app.
Refresh
On every request that reads the session, the jwt callback compares the clock with token.authio.refreshAt
(tokenRefreshSkew before expiry). When due, it redeems the refresh token with @hyper.authio/client, stores the
rotated refresh token (and a new ID token when Authio issues one, validated first), and reads the claims again from the
new access token (or its introspection), so a role granted or removed in the console reaches the session on the next
refresh.
- Concurrency. Requests that arrive together with the same cookie share one refresh, and a request still carrying the old cookie in the next 30 seconds gets the same result instead of presenting a spent refresh token, which Authio's reuse detection would treat as theft.
update(). NextAuth'supdate()(trigger: "update"in thejwtcallback) renews right away, whateverrefreshAt, so claims changed at Authio (a role, a sign-up completed, an invitation accepted) reach the session without a new sign-in. With asessionStoreit goes through the store's lock (see Renewal). A session without a refresh token stays as it is. Ajwtcallback of your own handstriggeron by passingparamswhole, as in Your own callbacks.- Refused. When Authio refuses the refresh token (
invalid_grant, a revoked or expired one), the session getserror: "RefreshAccessTokenError",session.authiodisappears and the refresh token is dropped: send the person to sign in again. A network failure, a429or a5xxalso sets the error but keeps the refresh token, and the next request tries again. - Where the cookie is written. A refresh is only kept if the response can set the cookie: route handlers, server
actions, the middleware (
proxy.ts) andgetServerSessionin API routes can; a server component cannot. Run the session through the middleware on the pages that need it (v5: wrapproxyinauth; v4: callgetServerSession). With asessionStorethe renewal is written to the store, so it is kept whatever the response.
// a component: the session can no longer be used
if (session?.error === "RefreshAccessTokenError") return <SignInAgain />;Lightweight and reference access tokens
A client's access token format in Authio is Jwt, Lightweight or Reference. A Lightweight token is a JWT that
keeps who signed in and how (sub, client_id, scope, sid, auth_time, acr, amr, act, org_id,
org_alias…) and leaves roles and permissions (*_resource_access), subscription, the profile and the e-mail to
introspection; a Reference token is an opaque handle. Read from the token alone, such a session has no roles.
createAuthio({ /* … */ accessTokenClaims: "introspection" });With "introspection", on sign-in and on every refresh the package introspects the access token at Authio,
authenticated as the client (which must therefore be confidential), and merges the answer over the ID token's claims;
extraClaims are read from the answer too. A Lightweight token is never guessed from the claims it lacks: without
the option, its session simply has no roles. It costs one request per sign-in and per refresh; navigating costs none,
the claims stay in the cookie as before, with or without a sessionStore.
A Reference token (no dot) is introspected in the default mode too. There active: false only leaves the claims out
and the session goes on with the ID token's, as before 5.1.0: that is a token requested for a resource (RFC 8707),
which Authio reports active only to that resource server's introspection client. An introspection that fails still
fails the sign-in.
| Introspection | Sign-in | Refresh |
| --- | --- | --- |
| "introspection", answers active: false | Fails | The session ends |
| "introspection", fails (network, 429, 5xx…) | Fails | The session keeps its claims |
| Default, reference token, answers active: false | Goes on with the ID token's claims (no roles) | The session keeps its claims |
| Default, reference token, fails | Fails | The session keeps its claims |
A failed sign-in ends on NextAuth's error page (error=Callback on v4, error=Configuration on v5), without a session
cookie. A refresh that keeps the claims keeps the renewed tokens too, without an error, and the next refresh reads the
claims again. A session that ends gets SessionEndedError on v4 and its cookie cleared on v5; with a sessionStore its
record is deleted.
Server-side sessions
By default the session cookie holds the access, refresh and ID tokens. When the client encrypts its tokens (nested
JWE), each weighs several KB: the cookie grows past what one cookie carries (about 4 KB), NextAuth splits it into
chunks, and a request's headers can pass the limits of proxies and hosts (nginx's 8 KB per header line, Vercel's 16 KB,
Node's 16 KB in all). The package warns once per process (console.warn) when a cookie session passes about 2,900
bytes of JSON.
With sessionStore the tokens stay on the server and the cookie keeps a reference:
// auth.ts, Auth.js v5 (the same options on NextAuth v4)
import Redis from "ioredis";
import { createAuthio } from "@hyper.authio/provider-nextauth/v5";
import { createRedisSessionStore } from "@hyper.authio/provider-nextauth/redis";
const redis = new Redis(process.env.SESSION_REDIS_URL!, { connectTimeout: 1000, commandTimeout: 500, maxRetriesPerRequest: 1 });
export const authio = createAuthio({
sessionStore: createRedisSessionStore(redis, { prefix: "my-app:session:" }),
sessionMaxAge: 30 * 24 * 60 * 60, // NextAuth's session.maxAge
extraClaims: ["subscription"], // optional: access token claims the app reads from the session
});The package does not depend on a Redis client. createRedisSessionStore takes any ioredis-compatible client (get,
set with EX and with PX ... NX, del, and eval as ioredis takes it: eval(script, numkeys, ...args);
Redis and Cluster both fit, and node-redis fits wrapped in those five methods). Any other store implements
AuthioSessionStore: get, set with a time to live, delete, and acquire / release of a lock, both atomic
across instances (release lets go of the lock only while it is still the caller's). Give the client short timeouts
and few retries: a store that does not answer should fail fast.
Turn it on when the session cookie is chunked (...session-token.0, .1), when a proxy or host refuses large
headers (431, 400 Request Header Or Cookie Too Large), when several instances renew the same session, or when
signing out must end the session on the server. Turning it on or off signs everyone out once (see
One format per mode).
What goes where
| Where | Key | What |
| --- | --- | --- |
| The session cookie, encrypted by NextAuth | token.authio | ref (128 random bits), key (256 random bits), expiresAt, refreshAt, signedInAt, claims |
| The store, for sessionMaxAge plus an hour after each write | {prefix}{ref} | { v: 1, accessToken, refreshToken, idToken, expiresAt, refreshAt, claims }, sealed with AES-256-GCM under key, a random IV and ref as additional data |
| The store, for 15 seconds at most | {prefix}{ref}:refresh | the renewal lock (SET ... PX 15000 NX), released by one EVAL that deletes it only while it still holds the value of the instance that took it |
- The tokens are kept as the cookie would hold them: the access and refresh tokens exactly as Authio issued them, JWE
included, and the ID token as the signed JWT
id_token_hintneeds. Whoever reads the store without the cookie opens nothing, and a record moved under another reference does not open either. - The claims stay in the cookie, so
session.authio,checkAccessin the middleware anduseSessionnever touch the store, and navigation goes on while the store is down. - The sealing uses WebCrypto only, on Node and on the edge; the store's client is what may need Node.
Reading the tokens
// app/api/orders/route.ts, Auth.js v5
import { AuthioSessionStoreUnavailableError } from "@hyper.authio/provider-nextauth/v5";
import { auth, authio } from "@/auth";
export const GET = auth(async (req) => {
try {
const accessToken = await authio.getAccessToken(await authio.getSessionToken(req, { secret: process.env.AUTH_SECRET! }));
if (!accessToken) return Response.json({ error: "SessionEnded" }, { status: 401 });
return fetch("https://orders.example.com/orders", { headers: { authorization: `Bearer ${accessToken}` } });
} catch (error) {
if (error instanceof AuthioSessionStoreUnavailableError) return Response.json({ error: "SessionStoreUnavailable" }, { status: 503 });
throw error;
}
});getAccessToken(token), asynchronous, the same call with or without a store: the session's access token (from the record, renewed through the store first when due),nullwhen the session cannot be used any more (none, anerror, refresh refused, record gone), or a rejection withAuthioSessionStoreUnavailableErrorwhen the store does not answer or an expired access token cannot be renewed yet (reason:store_unavailable,renewal_pending,renewal_failed). Answer that one503: the session is not over.endSessionUrlandrevokeread the record.endSessionUrlleavesid_token_hintout when the store does not answer, so signing out still works.discard(token)deletes the record and revokes its refresh token: call it with the previous session when a new sign-in replaces one.events.signOutdoes the same on sign-out.sessionStoreCache: true(or{ maxEntries }) keeps opened records in memory until theirrefreshAt, sparing one store read pergetAccessToken. Renewal never trusts it; a session discarded on another instance can still answer on this one until itsrefreshAt.
Renewal
When the cookie's refreshAt is due, the jwt callback reads the record:
- If the record is not due, another request or instance already renewed it: the cookie takes its times and claims.
- Otherwise the package takes the lock, reads the record again, renews at Authio, writes the record with a new time to live and lets go of the lock. Requests of the same instance share that renewal.
- A request that does not get the lock reads the record again every 200 ms for up to 3 seconds. It goes on with the renewed record, or with the current access token while it is still valid, or leaves the session as it is, without an error, for the next request to try again.
That is one refresh token exchange per session across every instance, inside Authio's reuse window, and it is kept even
when the response cannot write the cookie (a server component, the middleware without the auth wrapper): the next
request reads the renewed record by the same reference. A refused refresh token sets RefreshAccessTokenError, as
with a cookie session.
update() renews the same way without waiting for refreshAt: the record counts as due until it changes after the
request read it. So each update() makes one exchange, and update() arriving on two instances at once makes one: the
instance that does not get the lock adopts the record the other wrote. Zeroing the cookie's refreshAt does not force
anything with a store (the record's own refreshAt decides); call update() instead.
One format per mode
Each mode reads only its own cookie: with a sessionStore, token.authio is a reference (authio.ref); without one,
the tokens (authio.accessToken). A cookie of the other format is no session: the jwt callback ends it (Auth.js v5
clears the cookie, NextAuth v4 marks it SessionEndedError), getAccessToken answers null, endSessionUrl,
revoke and discard find nothing to read, and the person signs in again. Nothing moves between the formats, so
turning the store on or off signs everyone out once. Once the store is turned off, the records left in it expire on
their own, sessionMaxAge plus an hour after their last write.
Failures
| What fails | What happens |
| --- | --- |
| The store, at sign-in | The sign-in fails. The jwt callback rejects with AuthioSessionStoreUnavailableError (store_unavailable) and NextAuth handles it as any callback that throws: it logs the error and sends the browser to the error page (error=Callback on v4, error=Configuration on v5), without a session cookie. |
| The store, while navigating | Nothing: the claims are in the cookie. |
| The store, when the access token is due | The session stays as it is, without an error; getAccessToken rejects with store_unavailable. The next request tries again. |
| The store, at sign-out | The cookie is cleared anyway; the record expires on its own. |
| The record is gone (expired, deleted, store data lost) | The session ends: getAccessToken answers null, and the jwt callback ends the session once it is due. |
| Authio, at renewal | The session stays, without an error; getAccessToken rejects with renewal_failed once the access token expired. |
| Authio refuses the refresh token | RefreshAccessTokenError, as with a cookie session; the record keeps the ID token for endSessionUrl. |
Roles and permissions
The helpers take the session, the JWT (req.nextauth.token, getToken) or the claims, return false (or
unauthenticated) for a session with an error, and import nothing from Node: they run in the middleware.
import { checkAccess, hasPermission, hasRole, hasScope } from "@hyper.authio/provider-nextauth/v5"; // or the root entry on v4
hasRole(session, "billing-admin"); // case-insensitive
hasRole(session, "viewer", { sources: ["client"] }); // only roles granted through the client
hasPermission(session, "invoice", "write"); // Authio's grant rule
hasScope(session, "roles");
checkAccess(session, {
permissions: ["reports:read"], // or ["reports", "read"]
roles: ["auditor"], // every one
anyRole: ["admin", "owner"], // at least one
scopes: ["roles"],
authenticationLevel: 2, // acr ≥ 2
}); // "allowed" | "unauthenticated" | "forbidden"Check permissions rather than role names: a role can be renamed in the console, the permissions it grants are what the code depends on.
// proxy.ts (middleware.ts on Next.js 15 and earlier), Auth.js v5
import { NextResponse } from "next/server";
import { checkAccess } from "@hyper.authio/provider-nextauth/v5";
import { auth } from "@/auth";
export const proxy = auth((req) => {
const decision = checkAccess(req.auth, { permissions: ["reports:read"] });
if (decision === "unauthenticated") return NextResponse.redirect(new URL("/api/auth/signin", req.nextUrl.origin));
if (decision === "forbidden") return new NextResponse("Forbidden", { status: 403 });
return NextResponse.next();
});
export const config = { matcher: ["/admin/:path*"] };// middleware.ts, NextAuth v4
import { NextResponse } from "next/server";
import { withAuth } from "next-auth/middleware";
import { checkAccess } from "@hyper.authio/provider-nextauth";
export default withAuth(
(req) => (checkAccess(req.nextauth.token, { permissions: ["reports:read"] }) === "allowed" ? NextResponse.next() : new NextResponse("Forbidden", { status: 403 })),
{ callbacks: { authorized: ({ token }) => checkAccess(token) !== "unauthenticated" } },
);The v4 middleware reads the cookie without running the callbacks, so it sees a refresh or a back-channel logout only
once a request through getServerSession or /api/auth/session has written the cookie.
Calling an API
The access token is read on the server, off the JWT:
import { getToken } from "next-auth/jwt";
import { authio } from "@/auth";
const accessToken = await authio.getAccessToken(await getToken({ req, secret: process.env.AUTH_SECRET })); // null when expired or failed
await fetch("https://orders.example.com/orders", { headers: { authorization: `Bearer ${accessToken}` } });getToken reads the cookie the request brought. On NextAuth v4, getServerSession(req, res, authOptions) renews an
expiring access token and writes the new cookie on res, so read the token after it with
authio.getSessionToken(req, res): the call right after a refresh then carries the new access token instead of the one
that just expired.
// pages/api/orders.ts, NextAuth v4
const session = await getServerSession(req, res, authOptions);
const accessToken = await authio.getAccessToken(await authio.getSessionToken(req, res));On Auth.js v5, auth() and the auth(handler) wrapper run the jwt callback and attach the renewed cookie to the
final response only, after the handler ran. authio.getSessionToken(req, params) answers the JWT the package's jwt
callback returned for the request's session cookie when it renewed the tokens or ended the session (kept for 30
seconds, in memory), and otherwise decodes the cookie with getToken, which takes the same params (secret,
secureCookie, cookieName, salt, decode, logger). Call it after auth().
// app/api/orders/route.ts, Auth.js v5
export const GET = auth(async (req) => {
const accessToken = await authio.getAccessToken(await authio.getSessionToken(req, { secret: process.env.AUTH_SECRET! }));
return fetch("https://orders.example.com/orders", { headers: { authorization: `Bearer ${accessToken}` } });
});getAccessToken is asynchronous and the same call with a sessionStore, where it reads the token from the store and
can reject with AuthioSessionStoreUnavailableError (see Reading the tokens).
authio.client() is the @hyper.authio/client client the package uses, for introspection, UserInfo, token exchange
and the rest.
Logout
Clearing the NextAuth cookie only signs the person out of the app; their Authio session stays open and the next
sign-in goes straight through. To end it too, send the browser to Authio's end-session endpoint after signOut:
// app/actions.ts, Auth.js v5
"use server";
import { headers } from "next/headers";
import { getToken } from "next-auth/jwt";
import { authio, signOut } from "@/auth";
export async function signOutEverywhere() {
const token = await getToken({ req: { headers: await headers() }, secret: process.env.AUTH_SECRET });
await signOut({ redirectTo: await authio.endSessionUrl(token) });
}// pages/api/end-session.ts, NextAuth v4: the button asks for the URL, then calls signOut({ callbackUrl: url })
import type { NextApiRequest, NextApiResponse } from "next";
import { getToken } from "next-auth/jwt";
import { authio } from "@/lib/auth";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
res.json({ url: await authio.endSessionUrl(await getToken({ req })) });
}endSessionUrl(token, { postLogoutRedirectUri, state, uiLocales }) carries the session's ID token as id_token_hint
(Authio accepts an expired one; without it Authio asks the person to confirm and ignores the redirect),
post_logout_redirect_uri (default postLogoutRedirectUri, which must be registered on the client) and client_id.
authio.callbacks.redirect lets signOut continue to that endpoint, besides your own origin.
authio.events.signOut revokes the refresh token at Authio on every sign-out, even one that stays in the app, and
with a sessionStore deletes the session's record; authio.revoke(token) revokes by hand, authio.discard(token)
deletes and revokes.
Back-channel logout
When the person signs out at Authio, from another app or from the account pages, Authio posts a logout token to the
client's back-channel logout URI. The package's endpoint validates it with @hyper.authio/client
(validateLogoutToken: signature, iss, aud, header typ = logout+jwt, the back-channel event, no nonce, a
sub or a sid; decrypted first when encrypted) and records the logout.
// app/api/auth/backchannel-logout/route.ts (App Router, v4 or v5)
import { authio } from "@/auth";
export const POST = authio.backchannelLogout;// pages/api/auth/backchannel-logout.ts (Pages Router, v4)
import { authio } from "@/lib/auth";
export default authio.backchannelLogoutApiRoute;It answers 200, 400 with { error: "invalid_request" } for a token that does not validate, 503 when the realm's
keys cannot be read (Authio retries), and 405 for anything but POST, always with Cache-Control: no-store.
JWT sessions. The session lives in the browser's cookie, so the server cannot delete it. The logout goes to the
logoutStore instead, and the jwt callback asks the store on every request: a session whose sid was named, or whose
subject was named after it signed in, is ended. On v5 the callback returns null and Auth.js clears the cookie; on v4
the session gets error: "SessionEndedError" and loses its tokens and claims. The default store is in memory, which
only works with one server instance; with several (serverless, containers behind a load balancer), give it a store
they share:
import type { AuthioLogoutStore } from "@hyper.authio/provider-nextauth/v5";
const logoutStore: AuthioLogoutStore = {
async add({ sessionId, subject, loggedOutAt }) {
if (sessionId) await redis.set(`logout:sid:${sessionId}`, loggedOutAt, { EX: 30 * 24 * 3600 });
else if (subject) await redis.set(`logout:sub:${subject}`, loggedOutAt, { EX: 30 * 24 * 3600 });
},
async isLoggedOut({ sessionId, subject, signedInAt }) {
if (sessionId && (await redis.exists(`logout:sid:${sessionId}`))) return true;
const at = subject ? Number(await redis.get(`logout:sub:${subject}`)) : NaN;
return signedInAt <= at;
},
};
export const authio = createAuthio({ logoutStore });Keep records as long as a session lives (session.maxAge, 30 days by default).
Database sessions. The rows are on your server: delete them in onBackchannelLogout. The Authio subject is the
providerAccountId of the person's authio account.
createAuthio({
async onBackchannelLogout({ subject }) {
await prisma.session.deleteMany({ where: { user: { accounts: { some: { provider: "authio", providerAccountId: subject } } } } });
},
});Front-channel logout
Authio can also end the session through the browser: its signed-out screen loads the client's front-channel logout URI
in a hidden iframe, with iss and sid when the client requires the session. The package's page checks them with
@hyper.authio/client (validateFrontChannelLogout: iss must be the issuer, a sid needs its iss) and records
the logout of sid in logoutStore, so the jwt callback ends that session on its next request, as with back-channel
logout.
// app/api/auth/frontchannel-logout/route.ts (App Router, v4 or v5)
import { authio } from "@/auth";
export const GET = authio.frontchannelLogout;// pages/api/auth/frontchannel-logout.ts (Pages Router, v4)
import { authio } from "@/lib/auth";
export default authio.frontchannelLogoutApiRoute;It answers 200 with an empty page, 400 with { error: "invalid_request" } for another issuer or a sid without
iss, and 405 for anything but GET, always with Cache-Control: no-store. The app's session cookie does not reach
an iframe on Authio's page (SameSite=Lax), so turn session required on: without sid the request names no session,
and only onFrontchannelLogout is called. authio.handleFrontchannelLogout(query) does the same from your own route.
Step-up
An API protected by Authio answers 401 with WWW-Authenticate: Bearer error="insufficient_user_authentication",
acr_values="2", max_age=300 when the sign-in is not strong or recent enough (RFC 9470 §3; servers older than
Authio.AspNetCore 0.7.0 and @hyper.authio/express 0.8.1 answered 403). authio.stepUp reads the challenge whatever
the status with parseAuthioBearerChallenge and builds what createStepUpAuthorizationUrl asks for (acr_values,
max_age, and prompt=login for max_age=0):
const response = await fetch("https://orders.example.com/refunds", { method: "POST", headers: { authorization: `Bearer ${accessToken}` } });
const stepUp = await authio.stepUp(response); // or the header value; undefined when it is not such a challenge
if (stepUp) await signIn("authio", { redirectTo: "/refunds" }, stepUp.authorizationParams);The sign-in goes through NextAuth (the cookies of your checks), not through the client's authorization URL: only that
URL's parameters are used. Authio enforces the max_age it carries. Afterwards session.authio.authenticationLevel and acr show what was
reached.
Popup sign-in
Keep the page where it is and run the sign-in in a popup over it. The helpers live in their own entry point and work on both majors:
import { signInWithPopup, startPopupSignIn, completePopupSignIn } from "@hyper.authio/provider-nextauth/popup";The popup runs the provider's ordinary flow: NextAuth's callback still redeems the code on the server with its
cookies, and the session cookie lands on your origin. Authio receives display=popup and draws its screens to fit.
// app/auth/popup/page.tsx: starts the sign-in inside the popup
"use client";
import { useEffect } from "react";
import { signIn } from "next-auth/react";
import { startPopupSignIn } from "@hyper.authio/provider-nextauth/popup";
export default function PopupStart() {
useEffect(() => {
startPopupSignIn(signIn);
}, []);
return null;
}// app/auth/popup/done/page.tsx: reports to the page and closes the popup
"use client";
import { useEffect } from "react";
import { completePopupSignIn } from "@hyper.authio/provider-nextauth/popup";
export default function PopupDone() {
useEffect(() => {
completePopupSignIn();
}, []);
return null;
}// the button
const result = await signInWithPopup();
if (result.ok) router.refresh(); // update() with useSession on the Pages Router| | |
| --- | --- |
| Paths | /auth/popup and /auth/popup/done by default; signInWithPopup({ startPath }), startPopupSignIn(signIn, { donePath }). Same origin as the button. |
| Extra parameters | startPopupSignIn(signIn, { authorizationParams: { login_hint, ui_locales } }). |
| Result | Never rejects: { ok: true } or { ok: false, error } with popup_blocked, popup_closed, timeout (5 min, timeoutMs) or NextAuth's error. |
| Errors | Point pages.error at a page that calls completePopupSignIn() when window.opener is set, to report a refused sign-in to the button. |
| Headers | Not Cross-Origin-Opener-Policy: same-origin; use same-origin-allow-popups. |
Examples
examples/ has two complete
apps, NextAuth v4 on the Pages Router and Auth.js v5 on the App Router, covering every feature above. Its README walks
through the console setup and the environment variables.
Troubleshooting
| Symptom | Cause |
| --- | --- |
| No refresh_token, the session ends when the access token expires | offline_access was not granted (allowed on the client and requested in scope). |
| session.authio.roles empty | The roles scope was not granted; or the client's access token format is Lightweight and accessTokenClaims is not "introspection"; or it is Reference and the introspection answered active: false (a token requested for a resource is active only to that resource server's introspection client). |
| Sign-in fails with AuthioInactiveAccessTokenError | With accessTokenClaims: "introspection", the introspection answered active: false. For an access token requested for a resource, Authio answers only to that resource server's introspection client. |
| Sign-in fails with decryption_failed | The client encrypts to its own key and clientEncryptionKey / AUTH_AUTHIO_ENCRYPTION_KEY is missing or is another key. |
| Sign-in fails with legacy_algorithm | The client encrypts with RSA1_5; switch it to RSA-OAEP-256. |
| Sign-in fails with encryptionMethods accepts … | The client encrypts with a method that is not listed, such as A128GCM: list it in client.id_token_encrypted_response_enc (v4) or encryptionMethods (v5). |
| The Authio clientSecret is required with client_secret_basic | A public client: set client: { token_endpoint_auth_method: "none" }. |
| invalid_client: The client … authenticates with client_secret_post …, not client_secret_basic | client.token_endpoint_auth_method is not the client's method in the console. |
| invalid_request: The client … requires PKCE | The client has PKCE on and checks leaves pkce out (NextAuth v4's default is ["state"]). |
| invalid_algorithm: The algorithm 'HS256' is not available | The client signs with HS*: set client.id_token_signed_response_alg. |
| authentication_too_old | The person signed in longer ago than max_age / client.default_max_age allows. |
| The client.token_endpoint_auth_method 'client_secret_jwt' is not a method Authio has | Authio has client_secret_basic, client_secret_post, private_key_jwt and none. |
| RefreshAccessTokenError right after a deploy with several instances | Concurrent refreshes on different instances presented the same refresh token; set a sessionStore, which renews once across instances, or route the session through the middleware so one request refreshes first, or keep sessions sticky. |
| The session cookie is split into .0, .1...; 431 or Request Header Or Cookie Too Large; the warning The session holds ... bytes of JSON | The tokens (encrypted ones above all) make the cookie too large: set a sessionStore. |
| getAccessToken answers a Promise (Bearer [object Promise], a type error on string \| null) | Since 5.0.0 it is asynchronous: await authio.getAccessToken(token). |
| AuthioSessionStoreUnavailableError from getAccessToken | The store did not answer, or an expired access token could not be renewed yet: answer 503 and let the person stay signed in. |
| Sign-in ends on the error page (error=Callback on v4, error=Configuration on v5) with AuthioSessionStoreUnavailableError in the log | The sessionStore did not answer at sign-in, which fails it: check the store's address, credentials and timeouts. |
| Everyone was signed out after sessionStore was turned on or off | Expected: each mode reads only its own cookie, and a cookie of the other one is no session. |
| client.eval is not a function, or a type error on createRedisSessionStore(client) | Since 5.0.0 the Redis client needs eval(script, numkeys, ...args), as ioredis has it; wrap other clients. |
| Back-channel logout answered 200 but the session stays | Several server instances with the in-memory logoutStore: use a shared store. |
| UntrustedHost in production (v5) | NEXTAUTH_URL set instead of AUTH_URL. |
| Type errors importing the root entry in a v5 project | The root entry is the v4 one; import @hyper.authio/provider-nextauth/v5. |
| Subsequent property declarations must have the same type on Session.error | Your own next-auth.d.ts still declares error or the 2.x session fields: the package declares them now. |
Contributing
npm install
npm run typecheck # both majors, and the tests
npm test # vitest against @hyper.authio/testing's FakeAuthio, on next-auth v4 and @auth/core (v5)
npm run buildThe two entry points compile separately, against the two next-auth majors installed side by side (v5 through the
next-auth-v5 alias). src/core and src/profile.ts import nothing from next-auth. .npmrc sets
legacy-peer-deps=true so the next and react peers of next-auth are not installed: the package only needs their
types.
The conformance vectors in conformance/ are copied from authio-node (npm run sync-conformance, with authio-node
checked out next to this repository); conformance/PENDING lists what the provider does not run, and why.
