Next.js — Sign In with Wallet
This guide shows how to add Sign In with XRPL Wallet to a Next.js App Router project using iron-session for encrypted server-side sessions.
Install
npm install @xrpl-wallet-kit/next @xrpl-wallet-kit/client @xrpl-wallet-kit/auth
npm install iron-session
# Server-only peer deps — required on the backend
npm install ripple-keypairs@^3 xrpl@^41. Session configuration
Create a shared session config file. The password must be at least 32 characters.
// lib/session.ts
import type { SessionOptions } from "iron-session";
export interface SessionData {
address?: string;
authenticatedAt?: number;
pendingNonce?: string;
nonceIssuedAt?: number;
nonceOwner?: string;
}
export const sessionOptions: SessionOptions = {
password: process.env.SESSION_SECRET!, // min 32 chars
cookieName: "xrpl-wallet-session",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
maxAge: 60 * 30, // 30 minutes
},
};Add SESSION_SECRET to .env.local:
# .env.local
SESSION_SECRET=at-least-32-characters-random-secret-here
APP_ORIGIN=https://yourapp.example
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_project_id
NEXT_PUBLIC_XAMAN_API_KEY=your_xaman_api_key
XRPL_NODE_URL=wss://xrplcluster.com2. API routes
Implement lib/nonce-store in your backend. issue(nonce, expiresAt, owner) persists an unused challenge; consumeIssuedChallenge(parsed, owner) must conditionally consume an issued, unexpired nonce exactly once. The encrypted session's pendingNonce must match before consumption. Clearing a cookie alone cannot prevent parallel replay.
GET /api/auth/nonce
// app/api/auth/nonce/route.ts
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { generateNonce } from "@xrpl-wallet-kit/auth";
import { sessionOptions, type SessionData } from "@/lib/session";
import { nonceStore } from "@/lib/nonce-store";
export async function GET() {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions);
const nonce = generateNonce();
session.nonceOwner ??= generateNonce();
await nonceStore.issue(nonce, Date.now() + 300000, session.nonceOwner);
session.pendingNonce = nonce;
session.nonceIssuedAt = Date.now();
await session.save();
return NextResponse.json({ nonce });
}POST /api/auth/verify
// app/api/auth/verify/route.ts
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { parseAuthMessage, validateAuthMessage } from "@xrpl-wallet-kit/auth";
import { createXrplSignatureVerifier } from "@xrpl-wallet-kit/auth/verifiers";
import { nonceStore } from "@/lib/nonce-store";
import { sessionOptions, type SessionData } from "@/lib/session";
// Create verifier once — reused across requests
const verifier = createXrplSignatureVerifier({
nodeUrl: process.env.XRPL_NODE_URL ?? "wss://xrplcluster.com",
});
export async function POST(req: Request) {
try {
const body = await req.json();
const { message, signatureKind, signature, txBlob, address, publicKey } = body;
const session = await getIronSession<SessionData>(await cookies(), sessionOptions);
if (!session.pendingNonce) {
return NextResponse.json({ error: "No pending nonce." }, { status: 400 });
}
// Parse & validate message
let parsed: ReturnType<typeof parseAuthMessage>;
try {
parsed = parseAuthMessage(message);
} catch {
return NextResponse.json({ error: "Invalid message format." }, { status: 400 });
}
const origin = process.env.APP_ORIGIN!; // Trusted canonical origin, no trailing slash
const { valid, errors } = await validateAuthMessage(message, {
expectedDomain: new URL(origin).host,
expectedUri: origin,
expectedChainId: "xrpl:0", // Match the configured XRPL network
expectedAddress: address,
maxAgeSeconds: 300,
isNonceUsed: nonce => nonce !== session.pendingNonce,
});
if (!valid) {
return NextResponse.json({ error: errors }, { status: 400 });
}
// Verify signature
const ok = await verifier.verify({ message, signatureKind, signature, txBlob, address, publicKey });
if (!ok) {
return NextResponse.json({ error: "Signature verification failed." }, { status: 401 });
}
// Consume the issued nonce in a shared atomic store before persisting auth.
if (!session.nonceOwner || !await nonceStore.consumeIssuedChallenge(parsed, session.nonceOwner)) {
return NextResponse.json({ error: "Nonce used or expired." }, { status: 401 });
}
// Persist authenticated session
session.pendingNonce = undefined;
session.address = address;
session.authenticatedAt = Date.now();
await session.save();
return NextResponse.json({ ok: true, address });
} catch (err) {
console.error("[auth/verify]", err);
return NextResponse.json({ error: "Internal server error." }, { status: 500 });
}
}POST /api/auth/signout
// app/api/auth/signout/route.ts
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { sessionOptions, type SessionData } from "@/lib/session";
export async function POST() {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions);
session.destroy();
return NextResponse.json({ ok: true });
}GET /api/auth/me (protected route example)
// app/api/auth/me/route.ts
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { sessionOptions, type SessionData } from "@/lib/session";
export async function GET() {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions);
if (!session.address) {
return NextResponse.json({ error: "Not authenticated." }, { status: 401 });
}
return NextResponse.json({ address: session.address });
}3. WalletAuthAdapter (client-side)
Create a shared adapter that talks to the API routes above.
// lib/wallet-auth-adapter.ts
import { formatAuthMessage, type WalletAuthAdapter } from "@xrpl-wallet-kit/auth";
export const walletAuthAdapter: WalletAuthAdapter = {
async getNonce() {
const res = await fetch("/api/auth/nonce");
const data = await res.json();
return data.nonce as string;
},
createMessage(params) {
return formatAuthMessage(params);
},
async verify(params) {
const res = await fetch("/api/auth/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
return res.ok;
},
async signOut() {
await fetch("/api/auth/signout", { method: "POST" });
},
};4. Client component
// components/WalletSignIn.tsx
"use client";
import { useState, useEffect } from "react";
import { useWalletKit } from "@xrpl-wallet-kit/next";
import { createWalletAuth, type WalletAuth } from "@xrpl-wallet-kit/auth";
import { walletAuthAdapter } from "@/lib/wallet-auth-adapter";
export function WalletSignIn() {
const { manager, openModal, session } = useWalletKit();
const [auth, setAuth] = useState<WalletAuth | null>(null);
const [authState, setAuthState] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Create auth controller when wallet connects
useEffect(() => {
if (!session || !manager) {
if (auth) { auth.destroy(); setAuth(null); }
return;
}
const controller = createWalletAuth(manager, walletAuthAdapter, {
chainId: "xrpl:0",
statement: "Sign in to access the app.",
expiresIn: 3600,
});
const off = controller.on("change", setAuthState);
setAuth(controller);
setAuthState(controller.getState());
return () => { off(); controller.destroy(); };
}, [session, manager]);
async function handleSignIn() {
if (!auth) return;
setLoading(true);
setError(null);
try {
await auth.signIn();
} catch (err: any) {
setError(err.message ?? "Sign-in failed.");
} finally {
setLoading(false);
}
}
async function handleSignOut() {
if (!auth) return;
await auth.signOut();
}
if (!session) {
return <button onClick={openModal}>Connect Wallet</button>;
}
if (authState?.status === "authenticated") {
return (
<div>
<p>Signed in as: {authState.address}</p>
<button onClick={handleSignOut}>Sign Out</button>
</div>
);
}
return (
<div>
<p>Wallet: {session.account.address}</p>
{error && <p style={{ color: "red" }}>{error}</p>}
<button onClick={handleSignIn} disabled={loading}>
{loading ? "Signing…" : "Sign In with Wallet"}
</button>
</div>
);
}5. Root layout
// app/layout.tsx
import { WalletKitProvider } from "@xrpl-wallet-kit/next";
import { createWalletKit } from "@xrpl-wallet-kit/client";
// Build manager once (singleton outside component)
const kit = createWalletKit({
adapters: [
// ...your adapters
],
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<WalletKitProvider kit={kit}>
{children}
</WalletKitProvider>
</body>
</html>
);
}Security checklist
SESSION_SECRETmust be at least 32 random characters — never commit to git.- Set
secure: trueincookieOptionsin production (requires HTTPS). - Invalidate
pendingNonceafter use — the example does this at step 4. - Validate
expectedDomaininvalidateAuthMessage— prevents cross-origin replay. - Configure trusted
APP_ORIGINand verify the URI/chain, not the request's Host header. verifieris created once at module load, not per request.
This guide uses the existing XRPL signMessage() flow. For Joey Wallet's native login proof, use the separate native sign-in verifier. Do not send native proofs or Xahau authentication to this XRPL message endpoint.