By the end of this you will have WorkOS AuthKit running in a Next.js 15 App Router app: Google and email/password sign-in, a session that refreshes itself on every request, protected routes, a working sign-out, and redirect URIs that survive the trip from localhost to a Vercel preview to production. You will also know why the two errors in this setup say what they say, which is the part the quickstart leaves out.
What you need:
- Next.js 15 on the App Router.
@workos-inc/authkit-nextjs4.3.1 declares a peer range of^13.5.9 || ^14.2.26 || ^15.2.3 || ^16and an engines field of>=22.11.0for Node, so check your Node version before you start debugging anything else. - A WorkOS account with a Staging environment. Staging is where localhost is allowed.
- Ten minutes, and OpenSSL for one command.
What AuthKit stores, and where the Next.js App Router lets you write it
AuthKit does not keep server-side session state for you. The session, sealed with iron-session, lives in a cookie named wos-session by default, and a short-lived wos-auth-verifier cookie holds the PKCE code verifier and sealed OAuth state between the redirect out and the callback back.
That design collides with one App Router rule. The Next.js docs on cookies() put it plainly: "HTTP does not allow setting cookies after streaming starts, so you must use .set in a Server Function or Route Handler." Reading a cookie in a Server Component is fine. Writing one is not.
So every write AuthKit needs happens in middleware, a Route Handler, or a Server Action. The middleware does the refresh, the /callback Route Handler does the initial write, a Server Action does the sign-out. Keep that mapping and the error messages stop being mysterious.
1. Install and set four env vars
pnpm add @workos-inc/authkit-nextjs @workos-inc/node
openssl rand -base64 32
The second command prints 44 characters, comfortably over the minimum. Put it in WORKOS_COOKIE_PASSWORD. Anything shorter than 32 characters and updateSessionMiddleware throws "You must provide a valid cookie password that is at least 32 characters in the environment variables."
# .env.local
WORKOS_API_KEY="sk_test_..."
WORKOS_CLIENT_ID="client_..."
WORKOS_COOKIE_PASSWORD="<44 chars from openssl>"
NEXT_PUBLIC_WORKOS_REDIRECT_URI="http://localhost:3000/callback"
The API key and client ID both come from the Staging environment in the WorkOS dashboard, and they are scoped to it.
In the dashboard, turn on the authentication methods you want under AuthKit: email and password, and Google OAuth. Then add http://localhost:3000/callback under Redirects and set it as the default redirect URI.
The NEXT_PUBLIC_ prefix on the redirect URI is deliberate. It makes the value readable inside the middleware bundle, which matters in step 6.
2. The authkit-nextjs middleware
Create middleware.ts at the project root, next to app/.
import { authkitMiddleware } from '@workos-inc/authkit-nextjs';
export default authkitMiddleware({
middlewareAuth: {
enabled: true,
unauthenticatedPaths: ['/', '/pricing', '/sign-in', '/callback'],
},
});
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
middlewareAuth.enabled flips the app to what the authkit-nextjs README calls "a 'secure by default' approach where every route defined in your proxy/middleware matcher is protected unless specified otherwise". Everything in unauthenticatedPaths stays public. Everything else redirects to AuthKit before your page code runs.
The matcher is the negative-lookahead form the README recommends. A bare '/:path*' catch-all works, but the README warns that it intercepts static assets like CSS, images and fonts, which breaks styles, particularly under Tailwind CSS v4. Excluding _next/static and _next/image costs nothing and saves an hour.
On every matched request the middleware reads the session cookie, refreshes the access token if it is close to expiry, writes the new cookie on the response, and seals the session into an x-workos-session request header. Your pages never touch the cookie. That is the point.
Verify: start next dev, open http://localhost:3000/pricing signed out. It should render, styles included.
3. Callback route and sign-in link
The callback path has to match the redirect URI exactly. If the URI is http://localhost:3000/callback, the handler goes at app/callback/route.ts, not app/auth/callback/route.ts.
// app/callback/route.ts
import { handleAuth } from '@workos-inc/authkit-nextjs';
export const GET = handleAuth({ returnPathname: '/dashboard' });
handleAuth exchanges the authorization code, verifies the PKCE verifier cookie, seals the session, and writes wos-session. It is a Route Handler, so it is allowed to.
For the sign-in link, generate the URL server-side rather than hardcoding an AuthKit URL:
// app/sign-in/route.ts
import { getSignInUrl } from '@workos-inc/authkit-nextjs';
import { redirect } from 'next/navigation';
export const GET = async () => redirect(await getSignInUrl());
Set http://localhost:3000/sign-in as the Sign-in URL, also called initiate_login_uri, in the dashboard's Redirects tab. Skip it and dashboard-initiated flows fail with "Missing required auth parameter". The README is direct about why: without the Sign-in URL, "WorkOS-initiated flows (such as impersonating a user from the dashboard) will fail because they cannot complete the PKCE/CSRF verification that this library enforces on every callback."
There is a getSignUpUrl() too, and a signUpPaths middleware option if you want certain paths to land on the sign-up screen instead.
4. Reading the user, and the two errors
// app/dashboard/page.tsx
import { withAuth } from '@workos-inc/authkit-nextjs';
export default async function DashboardPage() {
const { user } = await withAuth({ ensureSignedIn: true });
return <p>Signed in as {user.email}</p>;
}
withAuth() does not read the cookie. It reads the x-workos-session header the middleware already put on the request and unseals it, which is why it works in a Server Component at all. Call it in a layout if the whole subtree needs the user, in a page if one route does. Calling it several times on the same request is cheap.
Verify: sign out of everything, hit http://localhost:3000/dashboard. You should bounce to AuthKit, sign in, land back on /dashboard, and see a wos-session cookie in the Application tab of devtools.
Now the errors. People conflate them and they have different causes.
The first is thrown by AuthKit: "You are calling 'withAuth' on $ that isn't covered by the AuthKit middleware. Make sure it is running on all paths you are calling 'withAuth' from by updating your middleware config in 'middleware.(js|ts)'." The middleware did not run on that path, so the header withAuth needs is absent. Reproduce it by narrowing your matcher to ['/'] and reloading /dashboard. Fix it by widening the matcher.
The second is thrown by Next.js: "Cookies can only be modified in a Server Action or Route Handler." Something in your render path tried to write the session cookie during a Server Component render. In practice that is signOut() or refreshSession() called from a page or layout body instead of from a Server Action or a Route Handler. Move the call.
Do not wrap withAuth in a try/catch to make either one go away. It shows up in threads as a quick fix and it is worse than the error. The README: "Wrapping a withAuth({ ensureSignedIn: true }) call in a try/catch block will cause a NEXT_REDIRECT error", because redirect() in Next signals by throwing and has to be called outside a try/catch. You will swallow the redirect, get a blank page, and still have the misconfiguration underneath.
5. Sign out
// components/sign-out-button.tsx
import { signOut } from '@workos-inc/authkit-nextjs';
export function SignOutButton() {
return (
<form
action={async () => {
'use server';
await signOut();
}}
>
<button type="submit">Sign out</button>
</form>
);
}
The inline 'use server' is what makes the cookie delete legal. Pass signOut({ returnTo: 'https://example.com/goodbye' }) if you want a specific landing page; it defaults to /.
Verify: click it, then reload /dashboard. You should bounce to AuthKit again and wos-session should be gone.
6. Redirect URIs for localhost, preview, and production
WorkOS matches redirect URIs exactly, and the URI must exist in the dashboard before you send a user through the flow. A trailing slash difference is a mismatch. Production environments reject http:// and localhost outright, which is the reason your local config cannot be your production config.
Wildcards exist and are narrower than people assume. Per those docs, the wildcard "must be located in the subdomain furthest from the root domain", a URL "must not contain more than one wildcard", a wildcard "will not match across multiple subdomain levels", wildcards do not work on public suffix domains such as ngrok-free.app, and "a URL with a wildcard cannot be set as the default redirect URI."
Pointing one wildcard at every environment is the wrong move anyway. It defeats the separation WorkOS gives you for free: distinct keys, distinct user pools, distinct branding. One client shared between staging and production means a bug in a preview build can mint a session against real users.
For Vercel previews, use the redirectUri middleware option instead. @ijxy laid out the pattern in issue #103: build the origin from Vercel's own env vars and hand it to the middleware, where a custom value takes precedence over the environment variable.
const origin =
process.env.VERCEL_ENV === 'preview'
? `https://${process.env.VERCEL_URL}`
: `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`;
export default authkitMiddleware({
redirectUri: process.env.VERCEL_ENV
? `${origin}/callback`
: process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI,
middlewareAuth: { enabled: true, unauthenticatedPaths: ['/', '/pricing', '/sign-in', '/callback'] },
});
One cookie detail while you are here. WORKOS_COOKIE_MAX_AGE defaults to 34560000 seconds, which is 400 days, and that number is not arbitrary. As of Chrome M104, "cookies can no longer set an expiration date more than 400 days in the future", and anything longer is silently capped. Set it lower if you want shorter sessions. Set WORKOS_COOKIE_DOMAIN only if you actually share the session across subdomains.
What to change before production
- Create the production environment in WorkOS, generate its own API key, and register only HTTPS redirect URIs on domains you control. Per the WorkOS docs on environments, "API keys, organizations, connections, users, webhook endpoints, and branding are all scoped to a single environment and don't carry over between them." Your staging test accounts stay in staging, which is what you want.
- Keep
WORKOS_COOKIE_PASSWORDdistinct per environment. It is the seal key for the session, and sharing it across stages means a staging cookie is a valid production cookie. - If you upgrade to Next.js 16, the file moves to
proxy.tsand the export becomesauthkitProxy.authkitMiddlewareis now an alias carrying@deprecated Use 'authkitProxy' instead, so nothing breaks immediately. Do the rename when you upgrade.
What auth does not do for you
AuthKit tells you a human controls that Gmail address. It does not tell you whether this is the eleventh account that human opened this week, or whether the card behind the trial is one of forty being run against your checkout. Gmail addresses are free.
Screening signup and login is still your job, and the same App Router rule applies: do it where you are allowed to write, which is the /callback Route Handler or a Server Action.
That is the tool we built. Once AuthKit hands you a user, one Portreeve call with event_type: "signup" or "login", the WorkOS user.id as external_user_id, the request IP, and the email returns allow, review, or block in under 100 ms. A review never blocks the person in front of you: the sign-in completes, the event lands in a review queue, and if it is later denied a signed webhook reaches your server so you can revoke. The SDK fails open by default, so an outage returns allow with degraded: true rather than locking anyone out of your app. Free tier is 1,000 screened events a month, no card.
The quickstart is the same shape as the code above, handling verdicts covers what to do with each of the three, and the reason codes reference explains what came back and why. For the signup side we wrote up screening a Next.js signup route end to end, and for the login side, what account takeover looks like in the signals.
Screen your first thousand events free, no card: dashboard.portreeve.com.