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.
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. 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.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.org_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 revokes every token in the chain — the current access token, the refresh token, and any tokens still in flight from the same lineage. This is the standard OAuth 2.1 refresh-token theft protection. The user has to re-authenticate. Store the latest refresh token atomically and never retry a failed refresh with the old value.
Logout & revocation
Revoke a refresh token at any time by posting it to the revocation endpoint. The matching access token isn't revoked server-side — it expires on its own within 15 minutes, and there's no efficient way to invalidate JWTs 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.For a hard logout, revoke the refresh token and clear the user's session cookie. The short access-token TTL bounds the worst-case window for any in-flight requests.
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.
| 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 — 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."
}Gotchas
Things that will trip you up at least once. Bookmark this section.