npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Readme

Authio for NextAuth.js / Auth.js

NPM Version

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_method with client_secret_basic, client_secret_post, private_key_jwt or none, id_token_signed_response_alg including HS256/384/512) and the provider id. 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 iss parameter checked (RFC 9207) and the ID token validated by the client: signature with the client's algorithm, iss, aud, exp, payload typ = ID, azp, at_hash, and nonce and max_age when asked for.
  • Encrypted tokens (nested JWE) with RSA-OAEP-256 (Authio's default) and RSA-OAEP, on both majors.
  • Refresh inside the package: the jwt callback renews the access token before it expires, stores the rotated refresh token, shares one refresh among concurrent requests, and reports RefreshAccessTokenError on 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, hasPermission and hasScope for 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_hint and post_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_authentication challenge into the parameters of a new sign-in.
  • Popup sign-in, on both majors.

Upgrading from 4.x? In 5.0.0 getAccessToken is asynchronous (await authio.getAccessToken(token)), in both modes; token.authio is typed AuthioTokenState | AuthioSessionReference; and a Redis client for sessionStore needs eval. See CHANGELOG.md.

Contents

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}):

  1. 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).
  2. Redirect URI: https://your-app.example.com/api/auth/callback/authio (/callback/{id} with an id of your own), one per environment.
  3. Post-logout redirect URI: where Authio sends the browser after signing out, such as https://your-app.example.com/.
  4. Grant types authorization_code and refresh_token; PKCE on with S256, sent with checks: ["pkce", …].
  5. Scopes openid, profile, email, plus roles (roles and permissions in the access token) and offline_access (Authio issues a refresh token only with offline_access).
  6. Access token format Jwt, so roles can be read from it (a Reference token is opaque).
  7. For back-channel logout: back-channel logout URI https://your-app.example.com/api/auth/backchannel-logout. For front-channel logout: front-channel logout URI https://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" | undefined

session.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's update() (trigger: "update" in the jwt callback) renews right away, whatever refreshAt, so claims changed at Authio (a role, a sign-up completed, an invitation accepted) reach the session without a new sign-in. With a sessionStore it goes through the store's lock (see Renewal). A session without a refresh token stays as it is. A jwt callback of your own hands trigger on by passing params whole, as in Your own callbacks.
  • Refused. When Authio refuses the refresh token (invalid_grant, a revoked or expired one), the session gets error: "RefreshAccessTokenError", session.authio disappears and the refresh token is dropped: send the person to sign in again. A network failure, a 429 or a 5xx also 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) and getServerSession in API routes can; a server component cannot. Run the session through the middleware on the pages that need it (v5: wrap proxy in auth; v4: call getServerSession). With a sessionStore the 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_hint needs. 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, checkAccess in the middleware and useSession never 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), null when the session cannot be used any more (none, an error, refresh refused, record gone), or a rejection with AuthioSessionStoreUnavailableError when the store does not answer or an expired access token cannot be renewed yet (reason: store_unavailable, renewal_pending, renewal_failed). Answer that one 503: the session is not over.
  • endSessionUrl and revoke read the record. endSessionUrl leaves id_token_hint out 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.signOut does the same on sign-out.
  • sessionStoreCache: true (or { maxEntries }) keeps opened records in memory until their refreshAt, sparing one store read per getAccessToken. Renewal never trusts it; a session discarded on another instance can still answer on this one until its refreshAt.

Renewal

When the cookie's refreshAt is due, the jwt callback reads the record:

  1. If the record is not due, another request or instance already renewed it: the cookie takes its times and claims.
  2. 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.
  3. 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 build

The 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.

License

MIT