Backend Proxy (BFF) Integration
Put your own backend between the browser and Canopy's Direct API. It keeps the sign-in cookie on your own domain, so browsers that block third-party cookies cannot discard it and log your users out.
Overview
In the Backend-for-Frontend (BFF) pattern your own API is the only thing the browser talks to: your login form posts credentials to your backend, your backend calls Canopy's Direct API server-to-server, and the refresh token is stored in an httpOnly cookie on your domain. Canopy supports this natively: a request that carries a secret X-API-Key for the Environment receives the refresh token in the JSON body instead of a cookie, precisely so a trusted backend can hold the session itself. Your frontend keeps the short-lived access token in memory exactly as in the browser-direct flow; only the refresh leg changes.
The third-party-cookie problem
Browser-direct Direct API calls work from any origin for login, but not for staying signed in. The refresh token rides in an httpOnly cookie set on Canopy's domain, and when your app runs on your own domain that cookie is third-party:
Choosing an integration mode
Staying signed in needs the refresh cookie to come from the same site as your app. Two arrangements achieve that, and there is one case where you already have it. Pick by how much control you have over DNS:
If you can add a DNS record, start with a custom auth domain: it needs no backend code at all. Reach for the proxy when DNS is out of your hands, or when you're already routing API traffic through your own backend. A native mobile app does not need one: a publishable key marked native receives the refresh token in the response body directly (see Native & Mobile Apps).
Request flow
The proxy owns three endpoints (login, refresh, logout) and forwards each to Canopy with two credentials the browser never sees: the Environment's secret API key (header) and the publishable key or refresh token (body). It also forwards the browser's address in X-Canopy-Client-IP and its user agent in X-Canopy-Client-User-Agent, so Canopy's rate limits apply per user rather than to the proxy as a whole, and every session and remembered device is recorded as the person's browser rather than your server.
{ email, password } to POST /auth/login on your API.POST /v1/identity/auth/login with the X-API-Key header and { publishable_key, email, password }. Because the secret key is valid for the Environment, Canopy returns refresh_token in the body and sets no cookie.Authorization: Bearer <access_token>; your backend verifies it against Canopy's JWKS exactly as documented on the Direct API page.POST /auth/refresh on your API; the cookie rides along first-party. Your backend forwards the stored refresh token (body + X-API-Key) to Canopy, receives a rotated pair, overwrites its cookie, and returns the new access token.Reference implementation
The wire contract first, then the three handlers a proxy needs. Only the two headers and the rotation rules are specific to Canopy.
Login as a trusted backend (refresh token in body, no cookie)
curl -X POST https://auth.canopy-io.com/v1/identity/auth/login \ -H "Content-Type: application/json" \ -H "X-API-Key: $CANOPY_SECRET_API_KEY" \ -d '{ "publishable_key": "pk_...", "email": "ada@example.com", "password": "correct horse battery staple" }' # 200 OK # { # "data": { # "access_token": "eyJ...", # "token_type": "Bearer", # "expires_in": 900, # "refresh_token": "b3f1...", # "identity": { "id": "...", "email": "ada@example.com", ... } # } # } # (no Set-Cookie header, the session is yours to hold)
Refresh as a trusted backend
curl -X POST https://auth.canopy-io.com/v1/identity/auth/refresh \ -H "Content-Type: application/json" \ -H "X-API-Key: $CANOPY_SECRET_API_KEY" \ -d '{ "refresh_token": "b3f1..." }' # 200 OK: a fully rotated pair; the presented token is now dead # { # "data": { # "access_token": "eyJ...", # "token_type": "Bearer", # "expires_in": 900, # "refresh_token": "9c2e...", # "identity": { "id": "...", "email": "ada@example.com", "first_name": "Ada", "last_name": "Lovelace" } # } # }
The three handlers (login, refresh, logout)
const BASE = process.env.CANOPY_AUTH_BASE_URL;
const COOKIE = "app_refresh";
// httpOnly so no script can read it. Secure so it never crosses plain HTTP.
// SameSite=Lax so a cross-site POST cannot ride it. Path limited to your auth
// routes so it is not attached to every request your app makes.
const COOKIE_OPTIONS = {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/auth",
maxAge: 180 * 24 * 60 * 60 * 1000,
};
// One credential per request: the secret API key, and no Authorization header,
// which these calls do not need. The browser's address and user agent ride
// along, so Canopy throttles per person and records their device, not yours;
// both are honoured only because a secret key of the same Environment is on
// the same request.
async function callCanopy(path, body, req) {
const upstream = await fetch(`${BASE}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.CANOPY_SECRET_API_KEY,
"X-Canopy-Client-IP": req.ip,
"X-Canopy-Client-User-Agent": req.get("user-agent") ?? "",
},
body: JSON.stringify(body),
});
return { status: upstream.status, body: await upstream.json() };
}
// POST /auth/login
async function login(req, res) {
const { email, password } = req.body;
const { status, body } = await callCanopy(
"/login",
{ publishable_key: process.env.CANOPY_PUBLISHABLE_KEY, email, password },
req,
);
if (status !== 200) {
return res.status(status).json(body);
}
const { refresh_token, ...session } = body.data;
// An MFA challenge or an enrolment continuation carries no refresh token.
// Pass it through unchanged and let the frontend drive the next step.
if (refresh_token) {
res.cookie(COOKIE, refresh_token, COOKIE_OPTIONS);
}
return res.json({ data: session });
}
// POST /auth/refresh
async function refresh(req, res) {
const stored = req.cookies?.[COOKIE];
if (!stored) {
return res
.status(401)
.json({ error: { code: "auth.invalid_refresh_token" } });
}
const { status, body } = await callCanopy(
"/refresh",
{ refresh_token: stored },
req,
);
if (status !== 200) {
res.clearCookie(COOKIE, COOKIE_OPTIONS);
return res.status(status).json(body);
}
const { refresh_token, ...session } = body.data;
// Rotation: ALWAYS overwrite. The token you just presented is already dead,
// and presenting it again is treated as theft.
res.cookie(COOKIE, refresh_token, COOKIE_OPTIONS);
return res.json({ data: session });
}
// POST /auth/logout
// The refresh token alone ends its session, so this works even after the
// person's access token has expired.
async function logout(req, res) {
const stored = req.cookies?.[COOKIE];
if (stored) {
await callCanopy("/logout", { refresh_token: stored }, req);
}
res.clearCookie(COOKIE, COOKIE_OPTIONS);
return res.json({ data: { message: "Signed out" } });
}Rotation & cookie rules
The proxy inherits Canopy's rotation semantics, and two rules keep you out of trouble:
Gotchas
The proxy is deliberately thin, and most mistakes come from making it thicker than it needs to be.
Tell us how we can improve this guide.