Skip to main content

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+
Redirect URI Configuration

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

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

.env.local
# 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

auth.ts
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" } },
},
],
});
app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

Step 4: Add a login button

app/page.tsx
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

app/dashboard/page.tsx
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>;
}
tip

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 verification
  • iron-session: Encrypted session cookies

Step 2: Session Configuration

lib/session.ts
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

lib/auth.ts
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

app/api/auth/login/route.ts
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

app/api/auth/callback/route.ts
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

app/api/auth/me/route.ts
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

app/api/auth/logout/route.ts
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

components/UserButton.tsx
'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

app/dashboard/page.tsx
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

pages/api/auth/[...consentkeys].ts
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

.env.local
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

lib/api.ts
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

  1. Start ConsentKeys backend:

    cd backend
    npm run dev
  2. Start Next.js app:

    npm run dev
  3. 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_SECRET is 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_URL matches your app’s origin (e.g., http://localhost:3000)

"Headers already sent"

  • Don't call res.redirect() after res.json()

Next Steps