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.
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 updateinstead of a hand-port. - Proactive refresh out of the box. The middleware decodes the JWT's
exp/iatand routes safe-method navigations through/api/auth/refreshwhen 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.
verifyAccessTokenstops 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
createAuthioSignInHandleron 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-rolled | SDK helper |
|---|---|
middleware.ts — JWT verify + silent refresh + cookie rotate + sliding cookie | createAuthioMiddleware() |
app/api/auth/callback/route.ts — magic-link + OAuth code, JWKS verify, set cookies | createAuthioCallbackHandler() |
app/api/auth/refresh/route.ts — call /v1/auth/refresh, rotate cookies, redirect | createAuthioRefreshHandler() |
app/api/auth/logout/route.ts — server-side revoke + clear cookies + redirect | createAuthioSignOutHandler() |
lib/auth-cookies.ts — cookie-name constants, setSessionCookies, refreshAccessToken | delete — 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 directlyIf 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|.*\\..*).*)"],
};/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.tsStep 5 — Verify
pnpm typecheck— must be clean.pnpm build— must produce the same.next/output shape.- Manual smoke against your deploy URL:
- Visit
/sign-in; sign in via magic link. - Verify the session persists through navigation and the cookie expiry tracks the JWT's
expclaim. - 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/refreshand the cookie rotates. - Click "Sign out"; verify the auth-core session row is gone (
POST /v1/auth/sign-outin the network tab,204or401).
- Visit
What you keep, what changes, what you give up
- Keep: All cookie names + paths (the SDK defaults to
authio_session/authio_refresh; passsessionCookieName/refreshCookieNameto override). All security headers + matcher rules + custompublicPathsstay 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
| File | Before | After | Delta |
|---|---|---|---|
src/middleware.ts | ~180 | ~30 | −150 |
src/lib/auth-cookies.ts | ~150 | 0 (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 |
Read more
- @authio/nextjs reference — the full options matrix for every helper.
- Sessions and JWT/JWKS — what the cookies actually carry and how verification works at each layer.
- Session lifecycle and timeouts — refresh windows, idle / absolute timeouts, and how org policy interacts with cookie TTL.
