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

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:

Safari blocks third-party cookies today, and Chrome is deprecating them. The login response's Set-Cookie is silently dropped, so the first token refresh fails and the user is signed out after 15 minutes.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

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:

Same-site: your app is served from the same site as the Canopy API origin it calls (for example both behind one apex domain). The browser-direct flow on the Direct API page works as-is: the refresh cookie is first-party. This is rare outside embedded or reverse-proxied setups.Custom auth domain: you delegate a subdomain you own (auth.yourapp.com) to Canopy, which then answers on your own site, so the refresh cookie is first-party without you writing any auth code. Usually the least work; needs control of DNS for the subdomain. See Custom auth domain.Backend proxy (BFF, this page): your app and Canopy are different sites and delegating a subdomain isn't an option. Your backend proxies the handful of auth endpoints and owns the refresh cookie on your domain. Costs a small auth module in your API; robust in every browser, and the only option for native mobile.

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).

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 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:

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 mirror the posture Canopy uses for its own cookie (ca_identity_refresh_token, Path=/v1/identity/auth).

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.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.
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.