Backend Proxy (BFF) Integration
Run Direct API authentication through your own backend so the session cookie is first-party on your domain, the production-robust pattern for a browser app that lives on a different site than Canopy.
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
Three integration modes work in production today. Pick by where your app runs relative to the API origin your users hit, and 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, when you're already routing API traffic through your own backend, or when you need native mobile support.
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 its publishable key (body).
{ 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 a compact NestJS proxy you can adapt. The same shape works in any backend framework: the only Canopy-specific parts are the two headers and the rotation rules.
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..." # } # }
NestJS proxy controller (login + refresh + logout)
@Controller("auth")
export class AuthProxyController {
private readonly base = process.env.CANOPY_AUTH_BASE_URL;
private cookieOptions() {
return {
httpOnly: true,
secure: true,
sameSite: "lax" as const,
path: "/auth",
maxAge: 180 * 24 * 60 * 60 * 1000,
};
}
private async callCanopy(path: string, body: object) {
const upstream = await fetch(`${this.base}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.CANOPY_SECRET_API_KEY,
},
body: JSON.stringify(body),
});
return { status: upstream.status, body: await upstream.json() };
}
@Post("login")
async login(
@Body() dto: { email: string; password: string },
@Res({ passthrough: true }) res: Response,
) {
const { status, body } = await this.callCanopy("/login", {
publishable_key: process.env.CANOPY_PUBLISHABLE_KEY,
email: dto.email,
password: dto.password,
});
if (status !== 200) {
res.status(status);
return body;
}
const { refresh_token, ...session } = body.data;
// MFA-challenge and continuation responses carry no refresh token.
// Pass them through and let the frontend drive the next step.
if (refresh_token) {
res.cookie("app_refresh", refresh_token, this.cookieOptions());
}
return { data: session };
}
@Post("refresh")
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const stored = req.cookies?.app_refresh;
if (!stored) {
res.status(401);
return { error: { code: "auth.invalid_refresh_token" } };
}
const { status, body } = await this.callCanopy("/refresh", {
refresh_token: stored,
});
if (status !== 200) {
res.clearCookie("app_refresh", this.cookieOptions());
res.status(status);
return body;
}
const { refresh_token, ...session } = body.data;
// Rotation: ALWAYS overwrite. The presented token is dead.
res.cookie("app_refresh", refresh_token, this.cookieOptions());
return { data: session };
}
@Post("logout")
async logout(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const stored = req.cookies?.app_refresh;
if (stored) {
await this.callCanopy("/logout", { refresh_token: stored });
}
res.clearCookie("app_refresh", this.cookieOptions());
return { 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.