Because I've done the hair pulling already (This is not bulletproof)
Welcome to the frustrating world of authentication! This guide will walk you through implementing OpenID Connect (OIDC) authentication with Stytch and next-auth in your Next.js application.
Prerequisites:
"next-auth": "^5.0.0-beta.25"
(Because living on the beta edge is how we roll)"next": "15.2.0"
(Might work earlier but I'm not testing that 🤷♂️)"jsonwebtoken": "^9.0.2"
(I had an augment with jose)- Using the App router (Because it's 2023+, and we've moved on)
This file is where the authentication magic begins. It's like the bouncer at your application's VIP section - deciding who gets in and what wristband they get.
// app/libs/auth/index.ts
import NextAuth, { NextAuthConfig } from 'next-auth';
import getRoles from './getRoles';
export const authConfig: NextAuthConfig = {
providers: [
{
id: 'oidc',
name: 'oidc',
type: 'oidc',
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
issuer: process.env.OIDC_ISSUER_URL,
token: process.env.OIDC_TOKEN_URL,
userinfo: process.env.OIDC_USERINFO_URL,
authorization: {
url: process.env.OIDC_AUTHORIZATION_URL,
params: { scope: 'openid email profile' },
},
checks: ['pkce', 'state'],
profile(profile) {
return {
id: profile.sub,
email: profile.email,
name: profile.name,
image: profile.picture,
roles: profile.roles || [],
};
},
},
],
callbacks: {
jwt({ token, user, account }) {
if (account) {
// Forward the access token to your JWT
token.accessToken = account.access_token;
// Also store the ID token, used for signing out
token.idToken = account.id_token;
}
return token;
},
session({ session, token }) {
// Ensure all properties required are added to the session
session.accessToken = token.accessToken as string;
session.idToken = token.idToken as string;
const roles = getRoles({ session });
session.user = {
...session.user,
roles: roles as string[],
};
return session;
},
authorized({ auth }) {
// Simple auth check
return !!auth;
},
},
};
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
What's Actually Happening Here:
- We're configuring NextAuth with OIDC as our provider. OIDC is like OAuth's but it handles both authentication and identity.
- The
profile
function transforms the raw OIDC profile into the shape we want to use in our app. - The callbacks handle important stuff:
jwt
: Stores tokens so we can use them latersession
: Makes sure our session has all the user data we need, including rolesauthorized
: The simplest possible check
Pro tip: You can find most of these values in your OIDC provider's discovery document at /.well-known/openid-configuration
This little helper extracts roles from your JWT. It's like the X-ray machine that sees what powers your users actually have.
// app/libs/auth/getRoles.ts
import jwt from 'jsonwebtoken';
const getRoles = ({ session }): string[] => {
const userSession = session;
let roles: string[] = [];
if (userSession?.user) {
const accessToken = jwt.decode(userSession.accessToken);
roles = accessToken?.roles || [];
}
return roles;
};
export default getRoles;
Why This Matters: Stytch (and many OIDC providers) store user roles in the access token. This function decodes that token to extract those roles, allowing you to implement role-based access control without making additional API calls.
These routes handle the actual login and logout flows. Think of them as the clearly marked entrance and exit doors to your application.
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth, signIn } from '~/libs/auth';
export async function GET(request: NextRequest) {
const session = await auth();
// Get the redirectTo parameter from the URL if it exists
const searchParams = request.nextUrl.searchParams;
const redirectTo = searchParams.get('redirectTo') || '/';
// If user is already signed in, redirect
if (session) {
return NextResponse.redirect(new URL(redirectTo || '/', request.url));
}
return signIn('oidc', { redirectTo });
}
What This Does: This route makes logging in easy. It checks if you're already logged in (no point in signing in twice), and initiates the OIDC flow if you're not. The redirectTo
parameter ensures users end up where they wanted to go after logging in.
// app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth, signOut } from '~/libs/auth';
function getLogoutUrl(idToken: string, redirectTo: string) {
// Create a new URL object
const logoutDestinationUrl = new URL(process.env.OIDC_AUTHORIZATION_URL);
// This should be the same as the client_id as the one you logged in with
logoutDestinationUrl.searchParams.set('client_id', process.env.OIDC_CLIENT_ID);
// This should be where you want to redirect to after logging in
logoutDestinationUrl.searchParams.set(
'post_logout_redirect_uri',
`${process.env.NEXT_PUBLIC_URL}?logoutReturnTo=${redirectTo}`
);
// Without this, you will be asked if you want to "sign out"
logoutDestinationUrl.searchParams.set('id_token_hint', idToken);
// return the logout URL
return logoutDestinationUrl.toString();
}
/**
* Logout API route that redirects to the OIDC provider to clear the session
*/
export async function GET(request: NextRequest) {
const session = await auth();
// Get the redirectTo parameter from the URL if it exists
const searchParams = request.nextUrl.searchParams;
const redirectTo = searchParams.get('redirectTo') || '/';
// If user is already signed out, redirect
if (!session) {
return NextResponse.redirect(new URL(redirectTo || '/', request.url));
}
// Sign user out of `next-auth`
await signOut({
// prevent page from reloading so we can redirect to the logout
redirect: false,
});
// Redirect to the OIDC provider to clear the session
return NextResponse.redirect(getLogoutUrl(session.idToken, redirectTo));
}
Why This Extra Complexity: Standard signOut
from next-auth doesn't "fully" log out the user from the OIDC provider. This route ensures we clean up both the local session AND the OIDC provider session. It's like not just hanging up the phone but also ending the call on the provider's side. Without this, users might experience the dreaded "I logged out but somehow I'm still logged in" phenomenon.
The final piece of the UI puzzle - buttons that actually trigger these flows.
// app/components/auth-buttons.tsx
export function SignIn({
redirectTo,
...props
}: { redirectTo?: string }) {
const url = redirectTo ? `/api/auth/login?redirectTo=${encodeURIComponent(redirectTo)}` : '/api/auth/login';
return (
<a href={url} {...props}>Sign In</a>
)
}
export function SignOut({
redirectTo,
...props
}: { redirectTo?: string }) {
const url = redirectTo ? `/api/auth/logout?redirectTo=${encodeURIComponent(redirectTo)}` : '/api/auth/logout';
return (
<a href={url} {...props}>
Sign Out
</a>
)
}
Simple But Smart: These components are just fancy links that point to our auth routes. The beauty is in the simplicity - they handle the redirect parameter automatically, making them drop-in solutions for your UI. Now you can place login/logout buttons anywhere without repeating the logic.
Because TypeScript without proper types is just JavaScript with extra steps.
// types/next-auth.d.ts
import 'next-auth';
declare module 'next-auth' {
/**
* Extend the built-in session types
*/
interface Session {
accessToken: string;
idToken: string;
user: {
id: string;
email: string;
name: string;
image?: string;
roles: string[];
};
}
/**
* Extend the built-in user types
*/
interface User {
id: string;
name?: string;
email?: string;
image?: string | null;
roles: string[];
}
}
Why This Matters: This type definition tells TypeScript about our custom session and user properties. Without it, TypeScript would complain that roles
, accessToken
, etc. don't exist on the session object. Your autocomplete will thank you.
Middleware runs on every request, allowing you to implement auth checks before a route even renders.
// middleware.ts
import { auth } from '~/libs/auth';
import { NextResponse } from 'next/server';
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|images|favicon.ico|robots.txt|sitemap.xml|site.webmanifest).*)',
],
};
export default auth((req) => {
const { nextUrl } = req;
// If user is signed out and the path includes `/secret` redirect
if (!req.auth && nextUrl.pathname.includes('/secret')) {
const redirectUrl = new URL(`/api/auth/login`, nextUrl.origin);
redirectUrl.searchParams.append('redirectTo', req.nextUrl.pathname);
return NextResponse.redirect(redirectUrl);
}
// do other bits you might want to do...
return NextResponse.next();
});
The Power of Middleware: This middleware checks auth status before rendering protected pages. It's like having a bouncer who checks IDs before people even get to the door. The matcher pattern ensures we only run auth checks on actual pages, not on static assets (that would be inefficient and unnecessary).
// app/secret/page.tsx
import { auth } from '~/libs/auth';
import { SignIn, SignOut } from '~/components/auth-buttons';
import { redirect } from 'next/navigation';
export default async function SecretPage() {
const session = await auth();
if (!session || !session.user) {
return (
<div>
No session data, please{' '}
<SignIn className="inline-flex w-auto" redirectTo="/staff" />
first.
</div>
);
}
// Check if the member is a staff member
if (!session.user.roles.includes('staff')) {
redirect('/unauthorized');
}
return (
<div>
<pre>{JSON.stringify(session, null, 2)}</pre>
<br />
<SignOut redirectTo="/staff" />
</div>
);
}
Server-Side Auth Checks: Server components give you true server-side authentication checks. The page doesn't even render sensitive content for unauthorised users - they never even see it in the HTML.
// app/secret-client/page.tsx
"use client"
import { useSession } from "next-auth/react"
import { SignIn } from '~/components/auth-buttons';
export default function SecretClientPage() {
const { data: session, status } = useSession()
if (status === "loading") {
return (
<div>
<p>Loading... (Please wait while we verify your secret agent credentials)</p>
</div>
)
}
if (!session?.user) {
return (
<div>
<p>Access denied! You need to <SignIn>sign in</SignIn> first.</p>
</div>
)
}
return (
<div>
<h1>Welcome, Agent {session.user.name}</h1>
<p>Your clearance level: {session.user.roles.join(', ')}</p>
<pre>{JSON.stringify(session?.user, null, 2)}</pre>
</div>
)
}
Client-Side Auth Awareness: The useSession
hook gives client components access to the session state. This is perfect for interactive UIs that need to adapt based on the auth state. Remember, though, that client-side checks are for UX, not security. Never rely solely on client-side checks to protect sensitive data.
Congratulations! You've just implemented a "robust" OIDC authentication system with Stytch and next-auth. Your users can now securely log in and out, and your application knows exactly who they are and what they're allowed to do.
Remember:
- Server components for true security
- Client components for interactive UX
- Middleware for route protection
- The rest is just plumbing!