Next.js Integration
Integrate ConsentKeys authentication into your Next.js application using standard OAuth 2.0 / OpenID Connect (OIDC). This page includes a recommended integration (Auth.js / NextAuth) and an advanced “roll your own” integration (Route Handlers + iron-session).
Prerequisites
- A ConsentKeys Client ID and Secret from the Developer Portal
- Next.js 13+ (App Router) or Next.js 12+ (Pages Router)
- Node.js 18+
In the Developer Portal, register your redirect URI. For Auth.js / NextAuth use http://localhost:3000/api/auth/callback/consentkeys. For the custom Route Handler flow in this doc use http://localhost:3000/api/auth/callback.
Architecture
Recommended: Auth.js / NextAuth (App Router)
This is the simplest production setup: Auth.js handles PKCE, callback validation, and server-side sessions.
Step 1: Install
npm install next-auth
Step 2: Environment variables
# Your Next.js app URL
APP_URL=http://localhost:3000
# ConsentKeys (OIDC issuer + client credentials)
CONSENTKEYS_ISSUER=https://api.consentkeys.com
CONSENTKEYS_CLIENT_ID=ck_your_client_id
CONSENTKEYS_CLIENT_SECRET=your_client_secret
# Auth.js / NextAuth secrets
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=replace-with-a-long-random-secret
Step 3: Configure Auth.js
import NextAuth from "next-auth";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
{
id: "consentkeys",
name: "ConsentKeys",
type: "oidc",
issuer: process.env.CONSENTKEYS_ISSUER,
clientId: process.env.CONSENTKEYS_CLIENT_ID,
clientSecret: process.env.CONSENTKEYS_CLIENT_SECRET,
authorization: { params: { scope: "openid profile email" } },
},
],
});
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
Step 4: Add a login button
import { signIn } from "@/auth";
export default function HomePage() {
return (
<form
action={async () => {
"use server";
await signIn("consentkeys");
}}
>
<button type="submit">Sign in with ConsentKeys</button>
</form>
);
}
Step 5: Read the session server-side
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) redirect("/");
return <pre>{JSON.stringify(session.user, null, 2)}</pre>;
}
Auth.js uses OIDC discovery (/.well-known/openid-configuration) from your CONSENTKEYS_ISSUER to locate the ConsentKeys endpoints (/auth, /token, /userinfo, and /.well-known/jwks.json).
App Router (Next.js 13+)
This section shows a fully custom implementation using iron-session + direct calls to ConsentKeys endpoints.
Step 1: Install Dependencies
npm install jose iron-session
jose: JWT verificationiron-session: Encrypted session cookies
Step 2: Session Configuration
import { SessionOptions } from 'iron-session';
export interface SessionData {
user?: {
sub: string;
email: string;
name?: string;
picture?: string;
};
accessToken?: string;
codeVerifier?: string;
state?: string;
}
export const sessionOptions: SessionOptions = {
password: process.env.SESSION_SECRET!,
cookieName: 'ck_session',
cookieOptions: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 1 week
},
};
Step 3: Auth Utilities
import { jwtVerify, createRemoteJWKSet } from 'jose';
const CONSENTKEYS_ISSUER = process.env.CONSENTKEYS_ISSUER ?? 'https://api.consentkeys.com';
const JWKS_URL = `${CONSENTKEYS_ISSUER}/.well-known/jwks.json`;
// Verify ID token
export async function verifyIdToken(idToken: string) {
const JWKS = createRemoteJWKSet(new URL(JWKS_URL));
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: CONSENTKEYS_ISSUER,
audience: process.env.CONSENTKEYS_CLIENT_ID!,
});
return payload;
}
// Generate PKCE challenge
export function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(hash));
}
function base64UrlEncode(array: Uint8Array): string {
return Buffer.from(array)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
export function generateState(): string {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)));
}
Step 4: Login API Route
import { NextRequest, NextResponse } from 'next/server';
import { getIronSession } from 'iron-session';
import { cookies } from 'next/headers';
import { sessionOptions } from '@/lib/session';
import { generateCodeVerifier, generateCodeChallenge, generateState } from '@/lib/auth';
const CONSENTKEYS_ISSUER = process.env.CONSENTKEYS_ISSUER ?? 'https://api.consentkeys.com';
const CONSENTKEYS_AUTH_URL = `${CONSENTKEYS_ISSUER}/auth`;
const CLIENT_ID = process.env.CONSENTKEYS_CLIENT_ID!;
const REDIRECT_URI = process.env.APP_URL + '/api/auth/callback';
export async function GET(request: NextRequest) {
try {
// Generate PKCE parameters
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateState();
// Store in session for callback
const session = await getIronSession(cookies(), sessionOptions);
session.codeVerifier = codeVerifier;
session.state = state;
await session.save();
// Build authorization URL
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile email',
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
const authUrl = `${CONSENTKEYS_AUTH_URL}?${params}`;
return NextResponse.redirect(authUrl);
} catch (error) {
console.error('Login error:', error);
return NextResponse.json(
{ error: 'Failed to initiate login' },
{ status: 500 }
);
}
}
Step 5: Callback API Route
import { NextRequest, NextResponse } from 'next/server';
import { getIronSession } from 'iron-session';
import { cookies } from 'next/headers';
import { sessionOptions, SessionData } from '@/lib/session';
import { verifyIdToken } from '@/lib/auth';
const CONSENTKEYS_ISSUER = process.env.CONSENTKEYS_ISSUER ?? 'https://api.consentkeys.com';
const CONSENTKEYS_TOKEN_URL = `${CONSENTKEYS_ISSUER}/token`;
const CLIENT_ID = process.env.CONSENTKEYS_CLIENT_ID!;
const CLIENT_SECRET = process.env.CONSENTKEYS_CLIENT_SECRET!;
const REDIRECT_URI = process.env.APP_URL + '/api/auth/callback';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');
// Handle OAuth errors
if (error) {
const errorDescription = searchParams.get('error_description');
return NextResponse.redirect(
new URL(`/?error=${encodeURIComponent(errorDescription || error)}`, request.url)
);
}
if (!code || !state) {
return NextResponse.redirect(
new URL('/?error=missing_parameters', request.url)
);
}
// Get session to retrieve stored values
const session = await getIronSession<SessionData>(cookies(), sessionOptions);
// Verify state (CSRF protection)
if (state !== session.state) {
return NextResponse.redirect(
new URL('/?error=invalid_state', request.url)
);
}
const codeVerifier = session.codeVerifier;
if (!codeVerifier) {
return NextResponse.redirect(
new URL('/?error=missing_verifier', request.url)
);
}
// Exchange code for tokens
const tokenResponse = await fetch(CONSENTKEYS_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code_verifier: codeVerifier,
}),
});
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json();
return NextResponse.redirect(
new URL(`/?error=${encodeURIComponent(errorData.error_description)}`, request.url)
);
}
const tokens = await tokenResponse.json();
// Verify and decode ID token
const payload = await verifyIdToken(tokens.id_token);
// Store user in session
session.user = {
sub: payload.sub as string,
email: payload.email as string,
name: payload.name as string,
picture: payload.picture as string,
};
session.accessToken = tokens.access_token;
// Clear temporary values
delete session.codeVerifier;
delete session.state;
await session.save();
// Redirect to dashboard
return NextResponse.redirect(new URL('/dashboard', request.url));
} catch (error) {
console.error('Callback error:', error);
return NextResponse.redirect(
new URL('/?error=authentication_failed', request.url)
);
}
}
Step 6: User API Route
import { NextResponse } from 'next/server';
import { getIronSession } from 'iron-session';
import { cookies } from 'next/headers';
import { sessionOptions, SessionData } from '@/lib/session';
export async function GET() {
try {
const session = await getIronSession<SessionData>(cookies(), sessionOptions);
if (!session.user) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
return NextResponse.json({ user: session.user });
} catch (error) {
return NextResponse.json(
{ error: 'Session error' },
{ status: 500 }
);
}
}
Step 7: Logout API Route
import { NextResponse } from 'next/server';
import { getIronSession } from 'iron-session';
import { cookies } from 'next/headers';
import { sessionOptions, SessionData } from '@/lib/session';
export async function POST() {
try {
const session = await getIronSession<SessionData>(cookies(), sessionOptions);
session.destroy();
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: 'Logout failed' },
{ status: 500 }
);
}
}
Step 8: Client Components
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
interface User {
sub: string;
email: string;
name?: string;
picture?: string;
}
export function UserButton() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => {
fetch('/api/auth/me')
.then((res) => res.ok ? res.json() : null)
.then((data) => setUser(data?.user || null))
.finally(() => setLoading(false));
}, []);
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/');
router.refresh();
};
if (loading) {
return <div>Loading...</div>;
}
if (!user) {
return (
<a href="/api/auth/login">
<button>Sign In</button>
</a>
);
}
return (
<div>
{user.picture && <img src={user.picture} alt={user.name} width={32} height={32} />}
<span>{user.name || user.email}</span>
<button onClick={handleLogout}>Logout</button>
</div>
);
}
Step 9: Server-Side Access
import { cookies } from 'next/headers';
import { getIronSession } from 'iron-session';
import { redirect } from 'next/navigation';
import { sessionOptions, SessionData } from '@/lib/session';
export default async function DashboardPage() {
const session = await getIronSession<SessionData>(cookies(), sessionOptions);
if (!session.user) {
redirect('/api/auth/login');
}
return (
<div>
<h1>Dashboard</h1>
<p>Welcome, {session.user.name || session.user.email}!</p>
<p>Your ID: {session.user.sub}</p>
</div>
);
}
Step 10: Middleware Protection
Next.js Middleware runs on the Edge runtime, which is a constrained environment. If you use the custom iron-session approach, protect pages in Server Components (like the app/dashboard/page.tsx example above) and/or in Route Handlers instead of relying on Middleware.
If you use Auth.js / NextAuth, you can protect routes in middleware using Auth.js helpers (recommended).
Pages Router (Next.js 12)
Setup
import { NextApiRequest, NextApiResponse } from 'next';
import { withIronSessionApiRoute } from 'iron-session/next';
import { sessionOptions } from '@/lib/session';
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { consentkeys } = req.query;
const action = consentkeys[0];
if (action === 'login') {
return handleLogin(req, res);
}
if (action === 'callback') {
return handleCallback(req, res);
}
if (action === 'logout') {
req.session.destroy();
return res.json({ success: true });
}
if (action === 'me') {
if (!req.session.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
return res.json({ user: req.session.user });
}
return res.status(404).json({ error: 'Not found' });
}
async function handleLogin(req: NextApiRequest, res: NextApiResponse) {
// Similar to App Router login logic
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateState();
req.session.codeVerifier = codeVerifier;
req.session.state = state;
await req.session.save();
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.CONSENTKEYS_CLIENT_ID!,
redirect_uri: process.env.APP_URL + '/api/auth/callback',
scope: 'openid profile email',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
res.redirect(`https://api.consentkeys.com/auth?${params}`);
}
async function handleCallback(req: NextApiRequest, res: NextApiResponse) {
// Similar to App Router callback logic
const { code, state, error } = req.query;
if (error) {
return res.redirect(`/?error=${error}`);
}
if (state !== req.session.state) {
return res.redirect('/?error=invalid_state');
}
// Exchange code for tokens...
// (Same logic as App Router)
res.redirect('/dashboard');
}
Environment Variables
CONSENTKEYS_CLIENT_ID=ck_your_client_id
CONSENTKEYS_CLIENT_SECRET=your_client_secret
CONSENTKEYS_ISSUER=https://api.consentkeys.com
APP_URL=http://localhost:3000
SESSION_SECRET=complex-password-at-least-32-characters-long
Making API Calls
export async function callProtectedAPI(endpoint: string) {
const response = await fetch(`/api${endpoint}`, {
credentials: 'include',
});
if (response.status === 401) {
// Redirect to login
window.location.href = '/api/auth/login';
return null;
}
return response.json();
}
Testing
-
Start ConsentKeys backend:
cd backend
npm run dev -
Start Next.js app:
npm run dev -
Visit your Next.js app (e.g.,
http://localhost:3000) and test authentication
Security Best Practices
- ✅ Client secret never exposed to browser
- ✅ PKCE implemented for authorization code flow
- ✅ Session cookies are httpOnly and secure
- ✅ CSRF protection via state parameter
- ✅ ID tokens verified with JWKS
Troubleshooting
"Session not found"
- Ensure
SESSION_SECRETis at least 32 characters
"Redirect URI mismatch"
- Verify your redirect URI matches exactly in the Developer Portal (including protocol + path)
- For the custom flow, ensure
APP_URLmatches your app’s origin (e.g.,http://localhost:3000)
"Headers already sent"
- Don't call
res.redirect()afterres.json()
Next Steps
- Learn about magic link authentication
- Understand the OAuth flow
- Explore the API reference