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 primitivesjose— 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:30003. 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 protectionNext Steps
- Express.js Middleware — build a standalone API with credential verification
- Credential Flow — understand the full credential lifecycle
- SDK Reference — complete API documentation