1. Docs
  2. Authentication
  3. Backend Proxy (BFF)

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:

Browsers restrict third-party cookies, and several block them by default. The login response's Set-Cookie is silently dropped, so the first token refresh fails and the user is signed out as soon as the access token expires.Nothing fails at build time. Same-origin dev setups and tools like curl work perfectly; the breakage only appears in real browsers on your production domain, which is why this page exists.Access tokens are unaffected. They travel in the response body and the Authorization header, which are cross-origin-safe. Only the refresh cookie has a domain problem.

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:

Custom auth domain: you delegate a subdomain you own (auth.yourapp.com) to Canopy, which answers there instead of on the platform origin. The refresh cookie is then same-site with your app, and you write no auth code. Usually the least work; needs control of DNS for the subdomain. See Custom auth domain.Backend proxy (BFF, this page): delegating a subdomain is not an option, so your own backend answers the auth calls and owns the refresh cookie on your domain. Costs a small auth module in your API; robust in every browser.Already same-site: you route Canopy through your own domain with a reverse proxy you run, so the browser only ever sees your own hostname. The browser-direct flow on the Direct API page works as-is and nothing on this page applies. Uncommon, and it means operating that proxy yourself.

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.

Your login form posts { email, password } to POST /auth/login on your API.Your backend calls 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.Your backend sets its own httpOnly cookie on your domain carrying the refresh token, and returns the rest of the session (access token, identity profile) to the browser.The browser calls your APIs with Authorization: Bearer <access_token>; your backend verifies it against Canopy's JWKS exactly as documented on the Direct API page.When the access token nears expiry, the frontend posts to 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:

Overwrite the cookie on every refresh. Canopy rotates the refresh token on every use: the value you just presented is dead the moment the response arrives. Storing anything other than the newest token guarantees a replay.Never replay an old value. Presenting a superseded token is treated as theft: Canopy revokes every refresh token issued to that identity, and the user must sign in again. If your proxy runs multiple instances, the cookie is the single source of truth, so don't cache tokens in process memory.Scope your cookie tightly. httpOnly, Secure, SameSite=Lax, and a Path limited to your auth routes. Canopy's own cookie (ca_identity_refresh_token, Path=/v1/identity/auth) is SameSite=Strict on Canopy's host, and Lax only on a verified custom auth domain, where it is first-party.

Gotchas

The proxy is deliberately thin, and most mistakes come from making it thicker than it needs to be.

The secret API key never reaches the browser. It lives in your backend's environment. The publishable pk_… key is still required in the login body (that's what names the Environment), but it's non-secret; the X-API-Key is what unlocks body delivery of the refresh token.One credential per request, never two. Your proxy holds both an API key and, once a user is signed in, their access token, and an endpoint that accepts either answers 400 to a request carrying both. So choose per call: the auth endpoints you proxy (login, refresh, logout) go with X-API-Key; anything you call on a signed-in user's behalf goes with their Authorization: Bearer and no API key.Not every login response carries a refresh token. MFA challenges, grace prompts, and enrollment continuations return a prompt object instead. Pass those through unchanged and let your frontend drive the challenge endpoints through the same proxy.Don't log token values. Refresh tokens in your access logs are a breach waiting to happen; log outcomes, not payloads.CORS gets simpler, not harder. The browser only ever talks to your own origin, so your Canopy Environment's allowed-origins list matters only if you also use browser-direct calls elsewhere.Keep verification unchanged. Access tokens are validated against Canopy's JWKS exactly as in the browser-direct flow: the proxy changes how sessions persist, not what a token means.Passkeys run in the browser, on your domain. A proxy relays WebAuthn options and answers unchanged, but the ceremony itself happens on your origin, so the Environment must name your domain as its relying party first (webauthn_rp_id and webauthn_origins). Without that, the browser refuses before your server hears anything. See Passkeys and your domain.A remembered device is yours to hold, like the refresh token. When a challenge is answered with remember_device: true through your proxy, Canopy cannot set its device cookie on the browser, so the answer carries trusted_device_token and trusted_device_expires_at in the body. Store the token in a second httpOnly cookie on your domain and send it back as trusted_device_token in the body of that person's next login; without it, every sign-in asks for the second factor again. Offer the checkbox only when the Environment's mfa_trusted_device_days is above 0 (it is 0 by default), and read device_remembered on the answer. See Remember this device.Forward the browser's address, or every user shares one rate limit. The sign-in endpoints are throttled per client address, and to Canopy your proxy is one address for all of your users. Send the browser's IP in X-Canopy-Client-IP on every proxied identity-auth call. Canopy honours it only from a caller presenting a valid secret API key, and, when the request names a publishable key, only a secret key from that same Environment, so it cannot be forged from the outside or from another account, and keys the limits by it: one user hitting a limit no longer locks out the rest.Forward the browser's user agent too, or every session is your server. Canopy records where a session and a remembered device were signed in from, and through a proxy that is your server's address and HTTP client. Send the browser's User-Agent as X-Canopy-Client-User-Agent beside X-Canopy-Client-IP on every proxied call, including refresh; both are honoured under the same secret-key rule. The person's sessions list and remembered devices then name their laptop and their phone, not node at your address. To mark "this device" in a sessions UI, read is_current from GET /v1/identity/auth/sessions, called with the person's access token; its sid claim is the session, and it survives refreshes.
Read the full Direct API reference →
Environment
API version
v1.0
On this page Was this page helpful?

Tell us how we can improve this guide.