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 verificationiron-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
-
Start ConsentKeys backend:
cd backend
npm run dev -
Start Next.js app:
npm run dev -
Visit
https://pseudoidc.consentkeys.comand 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
NEXT_PUBLIC_BASE_URLmatches registered URI
"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