Part of Authio Lobby — Authio's drop-in passwordless authentication. Learn more at https://authio.com/products/lobby.
Next.js 14/15/16 adapter for Authio. Ships:
createAuthioMiddleware()— drop-in Edge middleware with silent session refresh.createAuthioSignInHandler()— drop-inapp/api/auth/sign-inhandler that mints the cookie-bound login-CSRF nonce (v0.3+).createAuthioCallbackHandler()/createAuthioRefreshHandler()/createAuthioSignOutHandler()— drop-inapp/api/auth/*route handlers.auth()— RSC helper that returns the verified session ({ userId, orgId, role }) inside Server Components and Route Handlers.authMiddleware()— the original v0.1 JWKS-verifying middleware. Still exported for backward compat.
- Embed
@useauthio/widgetsin App Router pages. Mint the widget JWT in a Server Component (or Server Action) using the existingauth()accessToken; render the widget inside a"use client"component. Full snippet on/sdks/nextjs. - Kind-aware route gating. Widget JWTs (
kind: "widget") are refused on every customer-session surface by the data plane, but if you ever embed your own JWKS verify in middleware (e.g. for a proxied admin tool), rejectkind === "widget"withwidget_token_not_allowed_here— the canonical refusal. - Roles + permissions on the session.
auth()exposessession.claims.roles(string in single-role mode, array in multi-role mode) andsession.claims.permissions(always an array). Both are reserved-claim names; the only override path is the Pattern 3 Actions hook (/actions/pattern-3-customer-roles). - MCP / DCR. Customers turning their product into an OAuth
provider can now flip
dcr_modeandcimd_enabledin the dashboard. The Next.js adapter is unchanged — the/.well-known/oauth-authorization-serveradvertisements are served byauth-coreand the new/oauth2/register//oauth2/cimd/resolveendpoints are reachable directly with any HTTP client. See/guides/mcp-integration.
pnpm add @useauthio/nextjs @useauthio/react// src/middleware.ts
import { createAuthioMiddleware } from "@useauthio/nextjs";
export default createAuthioMiddleware();
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };// app/api/auth/sign-in/route.ts (new in v0.3 — closes the login-CSRF gap)
import { createAuthioSignInHandler } from "@useauthio/nextjs/server";
export const { GET, POST } = createAuthioSignInHandler();
export const dynamic = "force-dynamic";// app/api/auth/callback/route.ts
import { createAuthioCallbackHandler } from "@useauthio/nextjs/server";
export const GET = createAuthioCallbackHandler();// app/api/auth/refresh/route.ts
import { createAuthioRefreshHandler } from "@useauthio/nextjs/server";
export const GET = createAuthioRefreshHandler();// app/api/auth/sign-out/route.ts
import { createAuthioSignOutHandler } from "@useauthio/nextjs/server";
export const { GET, POST } = createAuthioSignOutHandler();Point your "Sign in" link at the new handler — <a href="/api/auth/sign-in">Sign in</a>
— instead of the hosted-UI URL directly. The handler mints a CSRF nonce, sets
the authio_callback_state cookie on your origin, and redirects to Authio with
the same value as ?client_state_nonce=…. The callback handler then verifies
cookie ⟷ URL match before persisting any session.
That's it. You now have sign-in, silent refresh, sign-out, and login-CSRF defense — and your users stay signed in for the full org-policy refresh window (default 30 days) even though the underlying access JWT rotates every 15 minutes.
The v0.3 release closes a login-CSRF gap: an attacker who legitimately obtains an Authio access token
could otherwise craft /api/auth/callback?access_token=<their JWT> and trick a
victim into silently signing in as the attacker on the victim's browser.
The defense is the OAuth-style cookie-bound state nonce. The sign-in handler mints a 32-byte random nonce, sets it as an HttpOnly cookie on your origin, and forwards the same value to Authio. Auth-core persists it on the magic-link / OAuth state row and echoes it back on the callback redirect. The callback handler refuses any callback where cookie and URL disagree.
Callbacks now fail closed by default: the state cookie must match the callback
nonce and the access token must verify against Authio's JWKS. Add
createAuthioSignInHandler and point your sign-in link at
/api/auth/sign-in before upgrading.
If a staged migration is unavoidable, the temporary
dangerouslyAllowInsecureLegacyCallback: true callback option restores the old
fail-open behavior. Its name is intentional: this disables the state
requirement and default JWT verification, leaving the app open to login CSRF
and forged callback tokens. Remove it as soon as the sign-in-start route is
deployed. Passing verifyAccessToken: false alone does not weaken the secure
default.
import { auth } from "@useauthio/nextjs/server";
export default async function Page() {
const { userId, orgId } = await auth();
return <div>User {userId} in org {orgId ?? "(none selected)"}</div>;
}AUTHIO_PROJECT_ID is your dashboard environment ID (proj_…). The env
var name is legacy; the API field is still project_id. Set it in server
env so createAuthioSignInHandler() can forward it to Lobby (via a signed
ctx token when auth-core supports it, or legacy query params as fallback).
The sign-in handler's default hosted UI URL is https://auth.authio.com/.
Customers on the platform Lobby or a branded auth host pass hostedUiUrl:
createAuthioSignInHandler({
hostedUiUrl: process.env.AUTHIO_HOSTED_UI_URL ?? "https://lobby.authio.com/",
});For an explicit retry route that must show the sign-in panel even when Lobby
has a warm session, configure that route's handler with prompt: "login".
The prompt is included in signed Lobby context and does not put callback
errors or tokens in the redirect URL.
DNS for a vanity auth hostname (e.g. auth.acme.com) is dashboard-side: CNAME
to cname.authiodns.com. The SDK never references that CNAME at runtime —
only your hostedUiUrl / end-user sign-in URL changes. See
Custom domains.
Multiple BFFs under the same parent domain need distinct cookie names so they don't collide. Pass the same names to every helper:
const opts = {
sessionCookieName: "myapp_session",
refreshCookieName: "myapp_refresh",
apiUrl: process.env.AUTHIO_API_URL,
};
export default createAuthioMiddleware(opts);
export const GET = createAuthioCallbackHandler(opts);MIT