Authio docs

Migrate

Migrate from hand-rolled jose to @authio/nextjs

A side-by-side guide for Next.js BFFs that wired auth-core directly with the jose library. The reference: Daylight, who shipped this exact migration on 2026-05-22 in roughly 30 minutes.

This is the migration the 2026-05-21 Authio×Daylight security audit flagged as D2 (HIGH). Daylight ran it end-to-end on 2026-05-22 and is now the gold-standard reference. The diff in this guide mirrors the Daylight commit one-for-one.

Why migrate

A hand-rolled BFF — typically middleware.ts + a couple of app/api/auth/* route handlers + a lib/auth-cookies.ts module — works fine until Authio ships a platform change you have to track. New JWKS cooldown defaults, a new error code on /v1/auth/refresh, a new revoke endpoint URL, the EdDSA-only signer pin: each one needs a manual port into your codebase. The 2026-05-22 update of @authio/nextjs v0.3.1 ships every primitive a hand-rolled flow needs, with three knobs (proactiveRefreshThreshold, acceptOAuthCode, signOutPaths) that absorb the customisation Daylight had built up over six months.

What this migration buys you

  • One less codebase to keep in sync with auth-core. Authio platform changes propagate via pnpm update instead of a hand-port.
  • Proactive refresh out of the box. The middleware decodes the JWT's exp/iat and routes safe-method navigations through /api/auth/refresh when the token is in its last 25% of life. No more "user idle for 23h45m, comes back, and waits an extra redirect" boundary papercut.
  • Defense-in-depth callback verification. verifyAccessToken stops a forged-token-in-URL attack at the JWKS verifier instead of writing junk to the cookie jar.
  • D1 (login-CSRF) protection ready to wire. The cookie-bound state nonce machinery is already in the callback handler — adding the matching createAuthioSignInHandler on your sign-in page closes the gap with no further callback changes.
  • Sign-out alias fallback. Survives the rollout window for any auth-core deployment that hasn't yet shipped /v1/auth/sign-out.

Mapping the hand-rolled surface to the SDK

The Daylight migration deleted one file and rewrote four. Here's the surface map.

Hand-rolledSDK helper
middleware.ts — JWT verify + silent refresh + cookie rotate + sliding cookiecreateAuthioMiddleware()
app/api/auth/callback/route.ts — magic-link + OAuth code, JWKS verify, set cookiescreateAuthioCallbackHandler()
app/api/auth/refresh/route.ts — call /v1/auth/refresh, rotate cookies, redirectcreateAuthioRefreshHandler()
app/api/auth/logout/route.ts — server-side revoke + clear cookies + redirectcreateAuthioSignOutHandler()
lib/auth-cookies.ts — cookie-name constants, setSessionCookies, refreshAccessTokendelete — internal to the SDK now

Step 1 — Bump the SDK

pnpm add @authio/nextjs@^0.3.1
pnpm remove jose  # if you no longer use it directly

If your app reads SID claims directly via jose.decodeJwt, replace those call sites with the verifying auth() helper:

// Before
import { decodeJwt } from "jose";
const claims = decodeJwt(cookies().get("authio_session")!.value);
const sid = claims.sid;

// After
import { auth } from "@authio/nextjs/server";
const { sessionId: sid } = await auth({
  apiUrl: process.env.AUTHIO_API_URL,
  issuer: process.env.AUTHIO_ISSUER,
  audience: process.env.AUTHIO_AUDIENCE,
});

Step 2 — Replace the middleware

The hand-rolled src/middleware.ts is typically ~150–200 LOC. The SDK version is fifteen.

// src/middleware.ts
import { createAuthioMiddleware } from "@authio/nextjs";

export default createAuthioMiddleware({
  // Optional: trigger a redirect-through-/api/auth/refresh when the
  // access JWT has < 25% of its lifetime remaining. Recommended for
  // 24h JWT TTLs; leave undefined for the SDK default 15m TTL where
  // reactive refresh is fine.
  proactiveRefreshThreshold: 0.25,
  // Anything outside the matcher AND inside this list is auth-free.
  publicPaths: ["/sign-in", "/sign-up", "/api/auth/", "/_next/", "/favicon"],
});

export const config = {
  // Skip /api/* — the BFF handlers do their own auth, and the
  // middleware request-body clone trips proxyClientMaxBodySize on
  // large uploads.
  matcher: ["/((?!_next|api|.*\\..*).*)"],
};
One semantic difference to be aware of. The hand-rolled middleware did inline JWT verification on every request and rewrote the request cookie header so RSC fetches in the same request saw the rotated token. The SDK middleware instead redirects through /api/auth/refresh when the cookie is missing or close to expiring. This trades one extra redirect on the boundary navigation for a substantially simpler middleware and a one-order-of-magnitude reduction in JWKS round-trips during a cold-start burst. Verification still happens — at the RSC auth() helper and at every API request your backend serves.

Step 3 — Replace the route handlers

The hand-rolled handlers in app/api/auth/{callback,refresh,logout}/route.ts all collapse to a few lines each.

// app/api/auth/callback/route.ts
import { createAuthioCallbackHandler } from "@authio/nextjs/server";

export const GET = createAuthioCallbackHandler({
  apiUrl: process.env.AUTHIO_API_URL,
  signedInRedirect: "/projects",
  // Defense in depth — verify the JWT before persisting the cookie.
  verifyAccessToken: {
    issuer: process.env.AUTHIO_ISSUER,
    audience: process.env.AUTHIO_AUDIENCE,
  },
  // Set this if you wire auth-core's OAuth callback directly into
  // your BFF (Daylight does, for Google sign-in).
  acceptOAuthCode: true,
  // Forward this if your project resolves by header rather than by
  // host. Future-proofs against a custom-domain move.
  apiHeaders: { "X-Authio-Project": process.env.NEXT_PUBLIC_AUTHIO_PROJECT_ID! },
  // Match your auth-core access-JWT TTL. SDK default is 15m.
  accessCookieMaxAge: 24 * 60 * 60,
});

// app/api/auth/refresh/route.ts
import { createAuthioRefreshHandler } from "@authio/nextjs/server";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export const GET = createAuthioRefreshHandler({
  apiUrl: process.env.AUTHIO_API_URL,
  apiHeaders: { "X-Authio-Project": process.env.NEXT_PUBLIC_AUTHIO_PROJECT_ID! },
  accessCookieMaxAge: 24 * 60 * 60,
});

// app/api/auth/logout/route.ts
import { createAuthioSignOutHandler } from "@authio/nextjs/server";

export const { GET, POST } = createAuthioSignOutHandler({
  apiUrl: process.env.AUTHIO_API_URL,
  signedOutRedirect: "/sign-in",
  // Belt + suspenders: try the new alias first, then the legacy URL.
  // 401 (already revoked) is treated as success on both.
  signOutPaths: ["/v1/auth/sign-out", "/v1/sessions/revoke"],
  apiHeaders: { "X-Authio-Project": process.env.NEXT_PUBLIC_AUTHIO_PROJECT_ID! },
});

Step 4 — Delete the cookie helper module

Once nothing imports from your lib/auth-cookies.ts (the SDK exposes the cookie surface internally), delete it. This was ~150 LOC of setSessionCookies, clearSessionCookies, refreshAccessToken, shouldRefreshSoon helpers, and a constant table — all of it now in the SDK.

rm src/lib/auth-cookies.ts

Step 5 — Verify

  • pnpm typecheck — must be clean.
  • pnpm build — must produce the same .next/ output shape.
  • Manual smoke against your deploy URL:
    1. Visit /sign-in; sign in via magic link.
    2. Verify the session persists through navigation and the cookie expiry tracks the JWT's exp claim.
    3. Wait until the JWT is in its last 25% of life (or use a short test TTL); confirm the next navigation redirects briefly through /api/auth/refresh and the cookie rotates.
    4. Click "Sign out"; verify the auth-core session row is gone (POST /v1/auth/sign-out in the network tab, 204 or 401).

What you keep, what changes, what you give up

  • Keep: All cookie names + paths (the SDK defaults to authio_session / authio_refresh; pass sessionCookieName / refreshCookieName to override). All security headers + matcher rules + custom publicPaths stay exactly as written.
  • Changes: The middleware no longer verifies the JWT inline; verification happens at the RSC auth() layer and at every API request you serve. The middleware no longer inline-rewrites the request cookie header for RSC fetches; instead, refresh redirects through /api/auth/refresh. End-user UX is the same modulo a single extra redirect at most every proactiveRefreshThreshold × accessTokenLifetime (≈ once every 6h on a 24h TTL with a 0.25 threshold).
  • Give up: Any custom logic that lived between the cookie reads and the cookie writes — telemetry, custom error logging, ad-hoc rate limits — needs to move into your daylight-API layer or into a separate request interceptor. The SDK helpers are deliberately opinionated to keep the surface tight; if you need a hook the SDK doesn't expose, file an issue at authio-com/authio_nextjs and we'll add it as an option (the proactive-refresh + sign-out fallback knobs in 0.3.1 came from exactly this loop).

The Daylight diff, line counts

FileBeforeAfterDelta
src/middleware.ts~180~30−150
src/lib/auth-cookies.ts~1500 (deleted)−150
src/app/api/auth/callback/route.ts~165~50−115
src/app/api/auth/refresh/route.ts~95~25−70
src/app/api/auth/logout/route.ts~90~25−65
Total~680~130−550
Daylight finished the migration end-to-end (audit, SDK extension, replacement, build, deploy, post-deploy smoke) in a single ~30-minute coding session. With the SDK at 0.3.1+ you're downstream of that work, so most of yours is find-and-replace plus a careful smoke pass.

Read more