1. Docs
  2. Authentication
  3. Hosted Login

Hosted Login

Let Canopy host the login UI. Your app redirects to /oauth/authorize, the user signs in on Canopy's hosted page, and your callback receives an authorization code that you exchange for tokens.

Overview

Hosted Login is the standard OAuth 2.0 Authorization Code with PKCE flow plus OIDC identity claims: Canopy owns the sign-in UI, your app owns the callback. You send the user to /oauth/authorize, they authenticate against Canopy's hosted login page, and we redirect them back to your registered redirect_uri with a single-use authorization code. Your backend trades the code for an access_token, id_token, and refresh_token at /oauth/token. Choose this path when you don't want to render or maintain login UI yourself, can accept the redirect round-trip, and want OIDC discovery, JWKS verification, and refresh-token rotation out of the box.

Prerequisites

Before the redirect-and-receive-tokens flow will succeed end to end, you need these in place. Most are configured once per Environment.

The flow, end to end

Five steps from the user clicking your sign-in button to your backend holding their tokens. The diagram traces who talks to whom; the steps below give you the code for each leg.

1. Browser   →  /oauth/authorize       →  Canopy
2. Canopy    →  hosted login page      →  Browser
3. Browser   →  submits credentials    →  Canopy
4. Canopy    →  302 ?code&state        →  Browser  →  your callback
5. Backend   →  POST /oauth/token      →  Canopy
6. Canopy    →  access + id + refresh  →  Backend
7. Backend   →  session cookie         →  Browser
1. Generate a PKCE pair

Before redirecting, create a cryptographically random code_verifier and the SHA-256 hash of it (code_challenge). PKCE is mandatory: the token endpoint will reject any code exchange without a matching verifier. Store the verifier in your session, keyed by the state parameter you'll send next.

// Node.js: crypto module
import crypto from 'node:crypto';

const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
  .createHash('sha256')
  .update(codeVerifier)
  .digest('base64url');

// Persist `codeVerifier` keyed by the random `state` value.
// You'll need both back when the user lands on your callback.
2. Redirect to the authorize endpoint

Build the authorize URL with your client metadata, the requested scopes, an opaque state, and the PKCE challenge. Send the user there with a 302. Canopy renders the hosted login page in their browser. Send a nonce too if your client library offers one: Canopy echoes it into the ID token, and a client that sends one must find it there. Conformant libraries send it by default.

https://auth.canopy-io.com/oauth/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
  &scope=openid+profile+email
  &state=RANDOM_OPAQUE_STRING
  &code_challenge=BASE64URL_S256_HASH
  &code_challenge_method=S256
3. User signs in on the hosted page

Canopy handles the form, the password check, and any error messaging. There's no consent screen (scopes are pre-approved at client registration), so on success the user is redirected immediately. No code from your side.

4. Canopy redirects back to your callback

The browser hits your registered redirect_uri with ?code=…&state=…. The code is single-use and expires in 60 seconds. Validate state against the value you stored in step 1. If it doesn't match, treat it as a CSRF attempt and abort.

// Express callback
app.get('/callback', async (req, res) => {
  const { code, state } = req.query;
  const verifier = lookupVerifierForState(state);

  if (!verifier) {
    return res.status(400).send('Unknown or replayed state');
  }

  // Continue to step 5 with `code` and `verifier`.
});
5. Exchange the code for tokens

Your backend POSTs to /oauth/token with the code, your client credentials, the original redirect URI, and the PKCE verifier from step 1. The body can be either application/x-www-form-urlencoded (the RFC 6749 standard used by every OIDC client library) or application/json. Canopy accepts both. Client credentials go either in an Authorization: Basic header (client_secret_basic, what most client libraries default to) or in the body as client_id and client_secret (client_secret_post). Both are accepted and both are named in the discovery document; the header wins if you send both. The endpoint returns the access token, ID token, and refresh token. After this point the code is dead.

curl -X POST https://auth.canopy-io.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=AUTHORIZATION_CODE" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET" \
  -d "redirect_uri=https://app.example.com/callback" \
  -d "code_verifier=ORIGINAL_CODE_VERIFIER"

# 200 OK
# {
#   "access_token": "eyJ...",
#   "id_token": "eyJ...",
#   "refresh_token": "rt_...",
#   "token_type": "Bearer",
#   "expires_in": 900
# }

What's in the tokens

Three values come back from /oauth/token. The access token and ID token are RS256-signed JWTs verifiable against your JWKS endpoint. The refresh token is an opaque string Canopy uses for rotation. Never inspect or parse it.

Access token

Sent on every API call as Authorization: Bearer …. 15-minute lifetime: short enough that revocation isn't necessary; let it expire and refresh.

ID token

Carries identity claims about the authenticated user. Same standard claims as the access token (sub, iat, exp, iss) plus identity claims gated by the scopes you requested: email and email_verified with the email scope, name with the profile scope. Pass to your frontend if it needs to display the user's name or email; never use it to authorize API calls (that's the access token's job).

Refresh token

An opaque string (not a JWT). Backend-only: store it next to the user's session and trade it for a new access token when the current one expires. Treat it like a long-lived credential. See Refresh tokens below for rotation rules.

Verifying tokens

Every JWT you accept must be verified locally. Never trust the token without checking the signature, issuer, and expiration. Canopy publishes its public keys at the JWKS endpoint; cache them and refresh on a key-id miss.

Fetch the JWKS from https://auth.canopy-io.com/.well-known/jwks.json.Look at the JWT's kid header. Find the matching key in the JWKS by kid.Verify the RS256 signature locally with that public key.Reject if iss isn't https://auth.canopy-io.com, aud isn't your client_id, or exp is in the past.If you sent a nonce on the authorization request, check the ID token's nonce claim matches it. A missing or different value means the token answers some other request.Cache the JWKS in memory. On a kid miss, refetch once: Canopy may have rotated keys.

Node: using the jose library

import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL('https://auth.canopy-io.com/.well-known/jwks.json')
);

const { payload } = await jwtVerify(accessToken, JWKS, {
  issuer: 'https://auth.canopy-io.com',
  audience: 'YOUR_CLIENT_ID',
});

// payload.sub, payload.account_id, etc. are now trusted.

Refresh tokens

Refresh tokens last 180 days and are rotated on every use. Each call to /oauth/token with grant_type=refresh_token returns a new refresh token alongside the new access token. Save the new one immediately. The old one is dead the moment the response is sent.

Refresh request

curl -X POST https://auth.canopy-io.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=YOUR_REFRESH_TOKEN" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET"

# 200 OK
# {
#   "access_token": "eyJ...",
#   "refresh_token": "rt_NEW...",   ← save this; the old one is now revoked
#   "token_type": "Bearer",
#   "expires_in": 900
# }

Signing out

Signing out has two halves, and doing only the first is the most common mistake on this path. Revoking the refresh token stops your application minting new access tokens. It does nothing to the session at Canopy, which lives in a cookie on Canopy's origin that your application cannot reach — so the person stays signed in there, and the next sign-in returns them without a password. Do both.

Revoke the refresh token

Ends your application's own session. The matching access token isn't revoked server-side; it expires on its own, and there's no efficient way to invalidate a JWT without a per-request blocklist.

curl -X POST https://auth.canopy-io.com/oauth/revoke \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "token=YOUR_REFRESH_TOKEN" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET"

# 200 OK on success or if the token was already revoked / unknown.

A revocation is bound to the client the token was issued to: authenticating proves who is asking, not what they may revoke, so you cannot revoke another application's token. An unknown token, or one belonging to someone else, answers 200 all the same, so the endpoint cannot be used to discover which tokens exist.

End the session at Canopy

Send the browser to the end_session_endpoint from the discovery document — this is OpenID Connect RP-Initiated Logout, so a conformant library builds the URL for you (endSessionUrl() in most). Pass the ID token you kept from sign-in as id_token_hint: it identifies the session, and without it Canopy has to stop and ask the person to confirm rather than trust an unattributed request. Note that a refresh need not return a new ID token, so keep the one from sign-in rather than only the latest token set. post_logout_redirect_uri must exactly match a URL registered on the client — a separate list from your redirect URIs — and state comes back untouched.

// Most libraries build this for you
const url = client.endSessionUrl({
  id_token_hint: session.idToken,
  post_logout_redirect_uri: 'https://app.example.com/signed-out',
  state: 'opaque-value',
});

res.redirect(url);

// Or by hand:
// GET https://auth.canopy-io.com/auth/logout
//   ?id_token_hint=eyJ...
//   &post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2Fsigned-out
//   &state=opaque-value
Being told when a session ends elsewhere

If you run more than one application against the same Environment, the two steps above sign the person out of Canopy and of the application they were using. The others still hold their own tokens. Register a backchannel_logout_uri on each client and Canopy will POST a signed logout token there, server to server, whenever a session that client holds ends — including "sign out everywhere". Verify it before acting on it: check the signature against the JWKS, the issuer, the audience, and the events claim that distinguishes a logout token from an ID token replayed as one. Reject it if it carries a nonce, or if it names neither sub nor sid. The token names sub and no sid, so end every session you hold for that person.

// POST application/x-www-form-urlencoded, body: logout_token=<jwt>
app.post('/backchannel-logout', async (req, res) => {
  const { payload } = await jwtVerify(
    req.body.logout_token,
    createRemoteJWKSet(new URL('https://auth.canopy-io.com/.well-known/jwks.json')),
    { issuer: 'https://auth.canopy-io.com', audience: YOUR_CLIENT_ID },
  );

  if (!payload.events?.['http://schemas.openid.net/event/backchannel-logout'])
    return res.status(400).end();
  if (payload.nonce !== undefined) return res.status(400).end();
  if (!payload.sub && !payload.sid) return res.status(400).end();

  endEverySessionFor(payload.sub);
  res.status(200).end();
});

Error responses

Token-endpoint errors follow RFC 6749 §5.2: { "error": "...", "error_description": "..." }. The error code is machine-readable; the description is for logging and developer-facing UI, not end users. Malformed requests get the same shape, including the ones rejected before the endpoint runs, so your client library never has to parse two formats. The revocation endpoint answers the same way.

Sample error response

{
  "error": "invalid_grant",
  "error_description": "The authorization code is invalid or expired."
}
Errors on the authorization request

A refused /oauth/authorize request answers in the redirect, not in a body: a 302 to your registered redirect_uri carrying error, error_description and the state you sent, per RFC 6749 §4.1.2.1. Handle it in your callback alongside the success case: a callback that reads code and nothing else silently swallows every one of these. The exception is a request whose client_id or redirect_uri is itself wrong, which cannot be answered at an unverified URL and renders an error page instead. invalid_scope means the request asked for a scope this client is not registered for; scopes are an allowlist per client, set when you register it. unsupported_response_type means anything but code, and invalid_request here means a code_challenge_method other than S256.

Gotchas

Things that will trip you up at least once. Bookmark this section.

Client secret is shown only once. Capture it at creation. The original is never retrievable. If you miss it or it's compromised, use Rotate secret on the client to mint a fresh one.Scopes are an allowlist per client. A client may only ask for the scopes it was registered with. Asking for anything else fails the authorization request up front, before the sign-in page renders, and comes back to your callback as error=invalid_scope with your state.PKCE S256 is mandatory. No plain method, no skipping the challenge. Canopy will reject the token exchange.Authorization codes expire in 60 seconds and are single-use. Don't store them, don't retry with them. Move straight to the token exchange.Redirect URI is exact-match. No wildcards, no trailing-slash forgiveness. Pre-register every callback URL your app can reach.Refresh-token reuse triggers chain revocation. Save the new refresh token atomically before completing the response. Never retry a failed refresh with the old token.Revoking the refresh token is not signing out. It ends your application's session and leaves Canopy's own untouched, because that one lives in a cookie on Canopy's origin. Send the browser through the end_session_endpoint as well, or the next sign-in returns the person without asking for anything — and the hosted security page still opens for whoever has the browser next.ID-token claims are gated by scopes. If you don't request profile, you don't get name. If you don't request org, the ID token doesn't carry account_id, application_id, or environment_id, but the access token always carries them regardless of scopes.Validate state on every callback. Reject any callback whose state isn't one your app issued: that's how you catch replay and CSRF attacks.The permissions scope adds claims to the access token. Requesting it embeds the identity's resolved permissions as a permissions claim (plus permissions_overflow: true if the list is too large to fit). Request it only if you authorize inline from the token; skip it if you resolve permissions server-side.A custom auth domain does not move these endpoints. If you have configured one for the Environment, it serves the Direct API and the hosted lifecycle pages (password reset, email verification, invite acceptance), but /oauth/authorize, /oauth/token and OIDC discovery stay on the platform origin, and the iss claim keeps naming it. Hosted Login does not need the custom host the way a browser-direct integration does: tokens reach your app through the authorization-code exchange rather than a cookie your app's origin has to own.Point your client library at the issuer, not at individual endpoints. Canopy publishes a discovery document at https://auth.canopy-io.com/.well-known/openid-configuration naming every endpoint, the signing algorithm, the PKCE method and the client authentication methods it accepts. A conformant library configured with the issuer alone needs nothing else, and follows an endpoint that moves.
Environment
API version
v1.0
On this page Was this page helpful?

Tell us how we can improve this guide.