Skip to main content

Next.js Integration

Integrate ConsentKeys authentication into your Next.js application with full server-side session management.

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, set your redirect URI to http://localhost:3000/api/auth/callback for local development, or https://yourapp.com/api/auth/callback for production.

Architecture

App Router (Next.js 13+)

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;
}

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 JWKS_URL = 'https://pseudoidc.consentkeys.com/.well-known/jwks.json';
const CONSENTKEYS_ISSUER = 'https://pseudoidc.consentkeys.com';

// 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_AUTH_URL = 'https://pseudoidc.consentkeys.com/auth';
const CLIENT_ID = process.env.CONSENTKEYS_CLIENT_ID!;
const REDIRECT_URI = process.env.NEXT_PUBLIC_BASE_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_TOKEN_URL = 'https://pseudoidc.consentkeys.com/token';
const CLIENT_ID = process.env.CONSENTKEYS_CLIENT_ID!;
const CLIENT_SECRET = process.env.CONSENTKEYS_CLIENT_SECRET!;
const REDIRECT_URI = process.env.NEXT_PUBLIC_BASE_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

middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getIronSession } from 'iron-session';
import { sessionOptions, SessionData } from './lib/session';

export async function middleware(request: NextRequest) {
const response = NextResponse.next();

// Get session
const session = await getIronSession<SessionData>(
{ headers: request.headers, cookies: response.cookies },
sessionOptions
);

// Check if user is authenticated
const isAuthenticated = !!session.user;
const isProtectedRoute = request.nextUrl.pathname.startsWith('/dashboard');

// Redirect to login if accessing protected route without auth
if (isProtectedRoute && !isAuthenticated) {
return NextResponse.redirect(new URL('/api/auth/login', request.url));
}

return response;
}

export const config = {
matcher: ['/dashboard/:path*'],
};

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.NEXT_PUBLIC_BASE_URL + '/api/auth/callback',
scope: 'openid profile email',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});

res.redirect(`https://pseudoidc.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
NEXT_PUBLIC_BASE_URL=https://pseudoidc.consentkeys.com
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 https://pseudoidc.consentkeys.com 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 NEXT_PUBLIC_BASE_URL matches registered URI

"Headers already sent"

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

Next Steps