Skip to Content
GuidesNext.js Integration

Next.js Integration

Add decentralized identity authentication to a Next.js application using the Solidus SDK. This guide uses the App Router and covers server-side challenge creation, client-side DID auth, JWT session management, and route protection.

Prerequisites

  • Next.js 14+ with App Router
  • Node.js 18+

1. Install Dependencies

Solidus doesn’t publish a JWT package (@solidus-network/jwt was never released to npm — see the SDK overview). Session tokens are an ordinary application concern, unrelated to the DID signature verification below, so this guide uses jose, a standard package with first-class Edge/Next.js middleware support.

npm install @solidus-network/sdk @solidus-network/auth jose @noble/ed25519
  • @solidus-network/sdk — DID resolution, credential verification, chain queries
  • @solidus-network/auth — DID challenge-response authentication primitives
  • jose — sign and verify the app’s own session JWTs (HMAC)
  • @noble/ed25519 — key pair generation

2. Environment Variables

Create a .env.local file in your project root.

# Your app's Ed25519 private key (hex-encoded, 64 characters) — used by the # SDK for any signing operations your backend performs (e.g. did.create) SOLIDUS_PRIVATE_KEY=your_private_key_hex # Random secret for signing this app's own session JWTs (unrelated to DID # keys) — generate with: openssl rand -hex 32 SESSION_JWT_SECRET=your_session_secret_hex # Auth challenge domain — must match your deployment URL SOLIDUS_DOMAIN=localhost:3000

3. Create the SDK Instance

Create a shared SDK instance used by all server-side code.

// lib/solidus.ts import { createSdk } from '@solidus-network/sdk' export const sdk = createSdk({ mode: 'testnet', rpcUrl: 'https://rpc.solidus.network', signerPrivateKey: process.env.SOLIDUS_PRIVATE_KEY!, })

4. JWT Helper

Create a helper module for signing and verifying this app’s own session tokens (separate from — and downstream of — the DID challenge/presentation check in step 6).

// lib/session.ts import { SignJWT, jwtVerify } from 'jose' const secret = new TextEncoder().encode(process.env.SESSION_JWT_SECRET!) interface SessionPayload { did: string credentials: string[] } export async function createSessionToken( payload: SessionPayload ): Promise<string> { return new SignJWT({ credentials: payload.credentials }) .setProtectedHeader({ alg: 'HS256' }) .setSubject(payload.did) .setAudience(process.env.SOLIDUS_DOMAIN!) .setIssuedAt() .setExpirationTime('24h') .sign(secret) } export async function verifySessionToken( token: string ): Promise<SessionPayload | null> { try { const { payload } = await jwtVerify(token, secret, { audience: process.env.SOLIDUS_DOMAIN!, }) return { did: payload.sub as string, credentials: payload['credentials'] as string[], } } catch { return null } }

5. API Route: Create Challenge

This endpoint generates a DID authentication challenge that the user signs with their private key. createChallenge is a plain synchronous function — it returns the challenge directly, keyed to the DID being challenged; your route is responsible for persisting it (e.g. in a short-lived store) so step 6 can look it up by id.

// app/api/auth/challenge/route.ts import { NextResponse } from 'next/server' import { createChallenge } from '@solidus-network/auth' import { saveChallenge } from '@/lib/challenge-store' // your own storage export async function POST(request: Request) { const { did } = await request.json() const challenge = createChallenge(did) await saveChallenge(challenge) return NextResponse.json(challenge) // { id, did, nonce, issuedAt, expiresAt } }

6. API Route: Verify Presentation

After the user signs the challenge, this endpoint verifies the presentation and issues a session JWT. verifyPresentation takes the original challenge, the signed presentation, and a resolver that maps a verificationMethod ID to the signer’s raw Ed25519 public key (resolved from the holder’s on-chain DID document).

// app/api/auth/verify/route.ts import { NextResponse } from 'next/server' import { verifyPresentation } from '@solidus-network/auth' import { sdk } from '@/lib/solidus' import { createSessionToken } from '@/lib/session' import { loadChallenge } from '@/lib/challenge-store' import bs58 from 'bs58' export async function POST(request: Request) { const { challengeId, presentation } = await request.json() const challenge = await loadChallenge(challengeId) if (!challenge) { return NextResponse.json({ error: 'Unknown or expired challenge' }, { status: 401 }) } const verification = await verifyPresentation( challenge, presentation, async (verificationMethodId: string) => { const doc = await sdk.did.resolve(presentation.holder) const vm = doc?.verificationMethod.find((m) => m.id === verificationMethodId) if (!vm) throw new Error('Verification method not found') return bs58.decode(vm.publicKeyMultibase.slice(1)) // strip 'z' multibase prefix }, ) if (!verification.valid) { return NextResponse.json({ error: verification.error ?? 'Invalid presentation' }, { status: 401 }) } // Verify each credential referenced in the presentation, by ID const credentialTypes: string[] = [] for (const credential of presentation.verifiableCredential ?? []) { const result = await sdk.credentials.verify(credential.id) if (!result.valid) { return NextResponse.json( { error: 'Invalid credential', credentialId: credential.id }, { status: 401 } ) } credentialTypes.push(...credential.type) } // Issue this app's own session JWT const token = await createSessionToken({ did: presentation.holder, credentials: credentialTypes, }) const response = NextResponse.json({ token, did: presentation.holder, credentials: credentialTypes, }) // Also set as HTTP-only cookie for middleware access response.cookies.set('solidus-session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 60 * 60 * 24, // 24 hours path: '/', }) return response }

7. Client Component: Auth Button

A client component that triggers the DID authentication flow.

// components/solidus-auth-button.tsx 'use client' import { useState } from 'react' // @solidus-network/auth exports verify-side primitives (createChallenge, // verifyPresentation) — it does not ship a browser wallet connector. Signing // the challenge happens wherever the user's private key lives (a browser // extension, a deep link into the Identity app, or your own key management). // `signChallengeWithWallet` below is a placeholder for that integration. declare function signChallengeWithWallet( did: string, nonce: string, ): Promise<{ verificationMethod: string; jws: string }> interface SolidusAuthButtonProps { did: string onSuccess: (session: { did: string; token: string }) => void onError?: (error: Error) => void } export function SolidusAuthButton({ did, onSuccess, onError, }: SolidusAuthButtonProps) { const [loading, setLoading] = useState(false) async function handleAuth() { setLoading(true) try { // Step 1: Request a challenge from the server for this DID const challengeRes = await fetch('/api/auth/challenge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ did }), }) const challenge = await challengeRes.json() // challenge = { id, did, nonce, issuedAt, expiresAt } // Step 2: Sign the challenge nonce with the user's DID key const { verificationMethod, jws } = await signChallengeWithWallet(did, challenge.nonce) // Step 3: Build the VerifiablePresentation (see the // @solidus-network/auth reference for the exact shape) const presentation = { '@context': ['https://www.w3.org/2018/credentials/v1'], type: ['VerifiablePresentation'], holder: did, proof: { type: 'Ed25519Signature2020', created: new Date().toISOString(), challenge: challenge.nonce, proofPurpose: 'authentication', verificationMethod, jws, }, } // Step 4: Send the challenge ID + signed presentation to the server const verifyRes = await fetch('/api/auth/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ challengeId: challenge.id, presentation }), }) if (!verifyRes.ok) { throw new Error('Verification failed') } const session = await verifyRes.json() onSuccess({ did: session.did, token: session.token }) } catch (err) { onError?.(err instanceof Error ? err : new Error(String(err))) } finally { setLoading(false) } } return ( <button onClick={handleAuth} disabled={loading} style={{ padding: '12px 24px', borderRadius: '8px', border: 'none', backgroundColor: '#0A0A0A', color: '#FFFFFF', fontSize: '16px', cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? 0.7 : 1, }} > {loading ? 'Connecting...' : 'Sign in with Solidus'} </button> ) }

8. Middleware: Protect Routes

Use Next.js middleware to protect routes that require authentication.

// middleware.ts import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' import { jwtVerify } from 'jose' const PROTECTED_PATHS = ['/dashboard', '/profile', '/settings'] const secret = new TextEncoder().encode(process.env.SESSION_JWT_SECRET!) export async function middleware(request: NextRequest) { const isProtected = PROTECTED_PATHS.some((path) => request.nextUrl.pathname.startsWith(path) ) if (!isProtected) { return NextResponse.next() } const token = request.cookies.get('solidus-session')?.value if (!token) { return NextResponse.redirect(new URL('/login', request.url)) } try { const { payload } = await jwtVerify(token, secret, { audience: process.env.SOLIDUS_DOMAIN!, }) // Attach user info to headers for downstream use const response = NextResponse.next() response.headers.set('x-solidus-did', payload.sub as string) return response } catch { // Invalid or expired token — clear cookie and redirect const response = NextResponse.redirect(new URL('/login', request.url)) response.cookies.delete('solidus-session') return response } } export const config = { matcher: ['/dashboard/:path*', '/profile/:path*', '/settings/:path*'], }

9. Display User Identity

A server component that reads the session and displays the user’s DID and credentials.

// app/dashboard/page.tsx import { cookies } from 'next/headers' import { verifySessionToken } from '@/lib/session' import { sdk } from '@/lib/solidus' import { redirect } from 'next/navigation' export default async function DashboardPage() { const cookieStore = await cookies() const token = cookieStore.get('solidus-session')?.value if (!token) { redirect('/login') } const session = await verifySessionToken(token) if (!session) { redirect('/login') } // Resolve the user's DID document from the chain const didDocument = await sdk.did.resolve(session.did) return ( <div> <h1>Dashboard</h1> <section> <h2>Your Identity</h2> <dl> <dt>DID</dt> <dd>{session.did}</dd> <dt>Credentials</dt> <dd>{session.credentials.join(', ')}</dd> <dt>Verification Methods</dt> <dd>{didDocument.verificationMethod?.length ?? 0}</dd> </dl> </section> </div> ) }

Complete Project Structure

my-nextjs-app/ .env.local lib/ solidus.ts # SDK instance session.ts # JWT sign/verify helpers app/ api/ auth/ challenge/ route.ts # POST — create auth challenge verify/ route.ts # POST — verify presentation, issue JWT dashboard/ page.tsx # Protected page showing user identity login/ page.tsx # Login page with SolidusAuthButton components/ solidus-auth-button.tsx middleware.ts # Route protection

Next Steps

Last updated on