Eligibility SDK Integration Guide for Next.js (SSR)¶
This guide explains how to integrate the Eligibility SDK into a Next.js (App Router) application using a secure proxy, server-side authentication, and wallet signature verification. The SDK is designed to be flexible and works in both client-only and SSR setups.
Eligibility SDK Design Philosophy¶
The SDK is designed with a clear separation of responsibility:
SDK Responsibilities¶
- Provides a fully client-side UI for eligibility workflows.
- Calls backend endpoints defined by the SDK configuration.
- Supports secure proxying through a developer-supplied proxyUrl.
- Injects X-App-Auth header if getAuthToken() is provided.
- Warns in development mode when an API key is exposed on the frontend.
Developer Responsibilities¶
Developers are responsible for securing their backend proxy:
- Validate requests using session cookies, tokens, or signed headers.
- Rate limit incoming requests by IP/session to prevent abuse.
- Only allow forwarding to trusted backend domains and paths.
- Inject the x-api-key server-side (never expose on the frontend).
- Enforce CORS and domain origin checks.
SDK Configuration Overview¶
type EligibilitySDKConfig = {
network: "staging" | "testnet" | "mainnet";
proxyUrl?: string;
apiKey?: string;
getAuthToken?: () => string | undefined;
customHeaders?: Record<string, string>;
getCustomHeaders?: () => Promise<Record<string, string>>;
includeCredentials?: boolean;
};
- proxyUrl: Forwards SDK requests through a backend controlled by the developer.
- apiKey: Use only in secure server environments, not client-side.
- getAuthToken: If defined, SDK will attach this as X-App-Auth header.
Developer Guidance: Secure Proxy Implementation¶
While the SDK supports proxyUrl, it is up to the developer to:
- Read the x-api-backend header and validate the requested domain.
- Inject x-api-key in the proxy request using a secure environment variable.
- Verify requests using cookies or tokens.
- Apply rate limiting to protect from abuse.
- Restrict CORS to trusted frontend origins.
Example Proxy Route (App Router)¶
/app/server/createSecureProxy
import { NextRequest, NextResponse } from "next/server";
interface CreateSecureProxyOptions {
apiKey: string;
allowedDomains: string[];
rateLimit?: {
windowMs: number;
limit: number;
};
}
const rateMap = new Map<string, { count: number; lastReset: number }>();
const blockedIPs = new Set<string>(["192.168.0.1"]); // example of blocked IPs
export function createSecureProxy({
apiKey,
allowedDomains,
rateLimit,
}: CreateSecureProxyOptions) {
return async function handler(req: NextRequest) {
if (req.method === "OPTIONS") {
return new NextResponse(null, { status: 204 });
}
// Validate x-api-backend
const targetUrl = req.headers.get("x-api-backend");
if (!targetUrl) {
return NextResponse.json(
{ error: "Missing x-api-backend" },
{ status: 400 }
);
}
try {
const url = new URL(targetUrl);
const allowed = allowedDomains.some((domain) =>
url.hostname.endsWith(domain)
);
if (!allowed || url.protocol !== "https:") {
throw new Error("Blocked domain");
}
} catch (err) {
console.error("Invalid x-api-backend:", err);
return NextResponse.json(
{ error: "Invalid x-api-backend" },
{ status: 400 }
);
}
// Rate limit + block list
const ip =
req.headers.get("x-forwarded-for")?.split(",")[0].trim() || "unknown";
if (blockedIPs.has(ip)) {
return NextResponse.json({ error: "Forbidden IP" }, { status: 403 });
}
if (rateLimit) {
const now = Date.now();
const record = rateMap.get(ip) || { count: 0, lastReset: now };
if (now - record.lastReset > rateLimit.windowMs) {
record.count = 0;
record.lastReset = now;
}
record.count++;
rateMap.set(ip, record);
if (record.count > rateLimit.limit) {
return NextResponse.json(
{ error: "Too Many Requests" },
{ status: 429 }
);
}
}
// Forward the request
const headers = new Headers(req.headers);
headers.set("x-api-key", apiKey); // inject API key
const fetchOptions: RequestInit = {
method: req.method,
headers,
};
if (req.method !== "GET" && req.method !== "HEAD") {
fetchOptions.body = await req.text();
}
const response = await fetch(targetUrl, fetchOptions);
const contentType = response.headers.get("content-type") || "";
let nextRes: NextResponse;
if (contentType.includes("application/json")) {
const json = await response.json();
nextRes = NextResponse.json(json, { status: response.status });
} else {
const text = await response.text();
nextRes = new NextResponse(text, { status: response.status });
nextRes.headers.set("Content-Type", contentType);
}
return nextRes;
};
}
/app/api/sdk/route.ts
import { createSecureProxy } from "@/app/server/createSecureProxy";
const handler = createSecureProxy({
apiKey: process.env.AVERER_API_KEY!,
allowedDomains: ["idp.staging.redbelly.network", "idp.averer.co"],
rateLimit: { windowMs: 60000, limit: 10 },
});
export const GET = handler;
export const POST = handler;
/app/api/session/route.ts
import { SignJWT } from "jose";
import { NextRequest, NextResponse } from "next/server";
import { createPublicClient, http } from "viem";
import { redbellyTestnet } from "viem/chains";
import { parseSiweMessage } from "viem/siwe";
const JWT_SECRET = process.env.JWT_SECRET!;
const secret = new TextEncoder().encode(JWT_SECRET);
const client = createPublicClient({
chain: redbellyTestnet,
transport: http(),
});
export async function POST(req: NextRequest) {
const { message, signature } = await req.json();
try {
const valid = await client.verifySiweMessage({
message,
signature,
});
if (!valid) throw new Error("SIWE message verification failed");
const fields = parseSiweMessage(message);
// Use jose to create the JWT
const jwt = await new SignJWT({ sub: fields.address })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(secret);
const res = NextResponse.json({ success: true });
res.cookies.set({
name: "session-token",
value: jwt,
httpOnly: true,
secure: true,
path: "/",
sameSite: "strict",
maxAge: 60 * 60,
});
return res;
} catch (error) {
console.error("JWT creation or SIWE verification failed:", error);
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
middleware.ts
import { jwtVerify } from "jose";
import { NextRequest, NextResponse } from "next/server";
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function middleware(request: NextRequest) {
const token = request.cookies.get("session-token")?.value;
const pathname = request.nextUrl.pathname;
// Apply only to specific routes
if (!pathname.startsWith("/api/sdk")) {
return NextResponse.next();
}
if (!token) {
return NextResponse.json(
{ error: "Unauthorized: No token" },
{ status: 401 }
);
}
try {
const { payload } = await jwtVerify(token, secret);
console.log("JWT payload:", payload);
// Optionally, you can add more checks on the payload here
return NextResponse.next();
} catch (err) {
console.error("JWT verification failed:", err);
return NextResponse.json(
{ error: "Invalid session token" },
{ status: 401 }
);
}
}
export const config = {
matcher: ["/api/sdk"],
};