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 → Browser1. 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=S2563. 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.
| Claim | Type | Description |
|---|---|---|
sub | string | The authenticated identity's UUID. Use this as your stable user identifier. |
account_id / application_id / environment_id | string | The Account, Application, and Environment the identity is acting in, set from the OAuth client's env binding. Always present on identity access tokens regardless of which scopes were requested. (Each is also stamped as a matching _slug claim so the SPA can build env-scoped URLs without a lookup.) |
type | string | Always identity for hosted-login tokens. Distinguishes from platform-user tokens issued elsewhere. |
iat / exp | number | Issued-at and expires-at as Unix epoch seconds. exp is always 15 minutes after iat. |
iss | string | https://auth.canopy-io.com: verify this matches exactly. |
kid (header) | string | Key ID in the JWT header. Used to pick the right public key from the JWKS endpoint. |
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.
https://auth.canopy-io.com/.well-known/jwks.json.kid header. Find the matching key in the JWKS by kid.iss isn't https://auth.canopy-io.com, aud isn't your client_id, or exp is in the past.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.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 # }
If a refresh token is presented twice, Canopy treats it as theft and revokes every refresh token issued to that identity, the standard OAuth 2.1 refresh-token theft protection. Their current access token keeps working until it expires (within 15 minutes), but no new ones can be minted, so the user has to re-authenticate. Store the latest refresh token atomically and never retry a failed refresh with the old value.
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-valueBeing 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.
| Error code | When it fires |
|---|---|
invalid_client | Wrong client_id or client_secret, or the client doesn't exist in this Environment. |
invalid_grant | Authorization code expired (>60s), already used, doesn't match the redirect_uri, or the PKCE code_verifier doesn't hash to the original challenge. Also fired when a refresh token is revoked or has been replayed. |
invalid_request | Required parameter missing or malformed (no code, no grant_type, etc.). |
unauthorized_client | The client is not authorized to use the requested grant type. Rare: it only happens if you try a non-standard grant or the client config has been restricted. |
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.
Tell us how we can improve this guide.