Tied-agent walkthrough (Next.js + BFF)
A hands-on walkthrough of building a Next.js app as a tied-agent tenant under Assetera. An OIDC login, a server-side session that holds the tokens, and a tenant-gated proxy to the Marketplace API.
Build a Next.js app that embeds Assetera's catalog for your customers, as a tied-agent tenant. Your app ships a thin Backend-for-Frontend (BFF): a server layer, under your control, that holds all tokens and calls Assetera on the browser's behalf.
One rule above all: the browser never receives an access token. Tokens live server-side; the browser holds only an opaque, HttpOnly session cookie.
Under a tied-agent arrangement, Assetera runs KYC/AML and you operate under its licence. See Tenancy & responsibility for reliance vs tied agent, and Authentication for the OIDC grants and token contract (this page links to it rather than re-explaining it).
Runtime shape
Three parties: the browser, your BFF, and Assetera (Identity + Marketplace API). The browser only ever talks to your BFF.
The example project
A minimal App Router layout: a page, three auth route handlers, one catch-all proxy, and two lib modules.
Snippets below are correct in shape, not a complete app. They use the standard openid-client and
iron-session libraries; swap in your own equivalents. Environment hosts are never hardcoded; read them
from env (ASSETERA_ISSUER, etc.).
Walkthrough
Register your OIDC client
During onboarding, Assetera provisions you a confidential OIDC client bound to your tenant. You
register your redirect URI (/api/auth/callback) and receive a client ID plus a credential: a secret, or
better, a key pair for private_key_jwt (Assetera stores only your public key, so no shared secret crosses
the boundary). See Partner onboarding.
Your BFF discovers the issuer's endpoints from OIDC discovery:
// lib/oidc.ts
import * as client from "openid-client";
// e.g. https://auth.<base_domain>/realms/assetera
const issuerUrl = new URL(process.env.ASSETERA_ISSUER!);
export async function getConfig() {
return client.discovery(issuerUrl, process.env.ASSETERA_CLIENT_ID!, {
client_secret: process.env.ASSETERA_CLIENT_SECRET!,
});
}Login: redirect to Assetera (Auth Code + PKCE)
The login route generates a PKCE verifier, stashes it in the session, and redirects the browser to Assetera's hosted login. Assetera renders the sign-in methods (password, 2FA, passkey, SSO); you render nothing.
Callback: exchange the code server-side, store tokens in the session
Assetera redirects back with a short-lived code. Your BFF exchanges it server-to-server for an
access token and a refresh token, stores them in the server-side session, and sets an opaque
HttpOnly cookie. That cookie is all the browser ever gets. The tenant claim rides inside the token,
not the cookie.
Proxy: attach the bearer token and call the Marketplace API
Every data call from the browser hits your BFF (cookie only). The proxy looks up the session, attaches
the access token, and forwards to the Marketplace API. The API validates the token locally and scopes the
response to your tenant claim, so your customer sees only your catalog. Refresh happens here too, fully
server-side.
Gate on KYC before trading
Before showing trade controls, check the user's KYC status with the KYC SDK. The UI gate is UX only; the Marketplace API re-enforces eligibility on every order.
import { getKycStatus } from "@asseteragmbh/metakyc";
const status = await getKycStatus({ accessToken }); // server-side
if (status.level !== "verified") {
// redirect to KYC, or hide trade controls
}Trade
With a verified user and a valid session, a trade is a normal proxied call. The browser posts an order to your BFF; the BFF forwards it with the bearer token; the API checks tenant, eligibility, and limits, then executes. See the Marketplace API for order shapes.
// browser -> your BFF (cookie only)
await fetch("/api/orders", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ assetPair: "XYZ/EURe", side: "buy", qty: 10 }),
});Route handlers
// app/api/auth/login/route.ts
import * as client from "openid-client";
import { getConfig } from "@/lib/oidc";
import { getSession } from "@/lib/session";
export async function GET() {
const config = await getConfig();
const verifier = client.randomPKCECodeVerifier();
const challenge = await client.calculatePKCECodeChallenge(verifier);
const session = await getSession();
session.pkceVerifier = verifier;
await session.save();
const url = client.buildAuthorizationUrl(config, {
redirect_uri: process.env.ASSETERA_REDIRECT_URI!,
scope: "openid profile marketplace",
code_challenge: challenge,
code_challenge_method: "S256",
});
return Response.redirect(url.href);
}// app/api/auth/callback/route.ts
import * as client from "openid-client";
import { getConfig } from "@/lib/oidc";
import { getSession } from "@/lib/session";
export async function GET(req: Request) {
const config = await getConfig();
const session = await getSession();
const tokens = await client.authorizationCodeGrant(config, new URL(req.url), {
pkceCodeVerifier: session.pkceVerifier!,
});
// Tokens live server-side only. Browser gets nothing but the cookie.
session.accessToken = tokens.access_token;
session.refreshToken = tokens.refresh_token;
session.pkceVerifier = undefined;
await session.save();
return Response.redirect(new URL("/", req.url).href);
}// app/api/[...proxy]/route.ts
import { getSession } from "@/lib/session";
const API_BASE = process.env.ASSETERA_API_BASE!; // no hardcoded host
async function handle(req: Request, { params }: { params: { proxy: string[] } }) {
const session = await getSession();
if (!session.accessToken) return new Response("unauthenticated", { status: 401 });
const upstream = `${API_BASE}/${params.proxy.join("/")}`;
return fetch(upstream, {
method: req.method,
headers: {
authorization: `Bearer ${session.accessToken}`, // attached server-side
"content-type": req.headers.get("content-type") ?? "application/json",
},
body: req.method === "GET" ? undefined : await req.text(),
});
}
export { handle as GET, handle as POST };The catch-all proxy shown here forwards a broad path space for brevity. In production, allowlist the routes you expose and add refresh-on-401 so an expired access token is renewed server-side before retry.
Rules that keep it safe
- No token in the browser. No access/refresh token in
localStorage, query strings, or non-HttpOnly cookies. The cookie is opaque and HttpOnly, Secure, SameSite. - Authorization is the API's job. Your UI may hide or show controls for UX, but the Marketplace API and Assetera Identity enforce the real rules, re-checked on every call. Never move authorization into the browser.
- Tenant comes from the token, not the request. You cannot select or spoof a tenant on the wire; you get the tenant your client was issued for. (See Tenancy.)
Backend-only integration (no user browser)
If you have no interactive frontend (a server syncing catalog or acting on behalf of your book), skip the
BFF and use the Client Credentials grant: your backend authenticates as its own service client, receives
a token carrying your tenant claim, and calls the API directly. No user session, no cookie. See
Authentication.
Reference example project
A downloadable, runnable version of this walkthrough (the full Next.js example repo) is being packaged. Until then, the snippets above are the source of truth for the shape.
Authentication
The OIDC grants, local token validation, and the tenant claim in detail.
KYC SDK
Check and gate on a user's KYC/AML status before enabling trade.
Marketplace API
The endpoints your BFF proxy calls: catalog, orders, settlement.
Partner onboarding
Getting your OIDC client provisioned and going live.
Integrating with Assetera
Connect as a distribution partner. The two integration modes, the tenant model, and where the token flow is documented.
KYC integration (MetaKYC SDK)
Embed Assetera's KYC flow with the @asseteragmbh/metakyc React SDK. Create a session token on your server, then render the workflow in your app.