1. Docs
  2. Multi-Factor Auth

Multi-Factor Authentication

Add a second authentication factor for your identities, enforced per Environment and tightened per organization.

Overview

Multi-factor authentication (MFA) requires an identity to present a second factor, beyond their password, when they sign in. In Canopy, MFA is configured per Environment, so you can require it in production while leaving staging open, and it applies the same way whether your users authenticate through the hosted login pages or the Direct API. Your own administrators' second factor is a separate thing, enrolled on their profile and required per account; see Administrator Two-Factor Authentication under Administration.

Per-Environment enforcement

Five settings, scoped to each Environment

Every Environment carries its own MFA policy:

mfa_required: whether identities in this Environment must use MFA.mfa_grace_period_days: how long a newly-required identity can keep signing in before they're forced to enroll, from 0 to 30 days.mfa_trusted_device_days: how long a verified device is remembered before it's challenged again, up to 90 days. 0, the default, remembers nothing; see Remember this device.mfa_factor_allowlist: which factor types are permitted in this Environment.mfa_after_sso: whether single sign-on waives a second factor that is owed. require (the default) does not waive it, so an SSO sign-in is challenged whenever a password sign-in would be, when MFA is required or the identity enrolled a factor; exempt trusts the identity provider's own second factor and skips the challenge. Neither adds a challenge where none is owed, and neither matters when the provider reports the second factor it checked: an OpenID Connect ID token whose amr names one, or a SAML assertion whose authentication context does (Entra ID sends both; Okta sends neither over SAML), starts a session that already carries mfa and owes no challenge.

Factor types

Canopy supports four factor types:

Authenticator app (TOTP): a time-based 6-digit code from an app like Google Authenticator or 1Password.Passkeys & security keys (WebAuthn): phishing-resistant FIDO2 credentials: device passkeys or hardware keys. A passkey is bound to a domain; see Passkeys and your domain before you allow it for an app on your own domain.Email OTP: a one-time code sent to the identity's verified email address.Recovery codes: single-use backup codes for when a primary factor isn't available.

Login-time decisions

At sign-in, the response tells your integration which MFA step comes next:

No step: the session is issued as usual. That includes a device remembered within the trusted-device window, whose challenge is skipped.requires_mfa_challenge: true with an mfa_challenge object: the identity has a factor enrolled; prompt them to verify it. This applies whether or not MFA is required: an enrolled factor is always asked for.mfa_enrollment_pending: true with grace_expires_at: MFA is required but the grace window is still open; the session is issued and the user should be nudged to enroll.requires_mfa_enrollment: true with an mfa_enrollment token: MFA is required, the grace window has closed (or there is none), and no factor is enrolled; the user must enroll before a session is issued.An enrolment call refused with mfa.enrollment_token_invalid means that token is missing, malformed, or expired, most often because it timed out while the person fetched their authenticator. Send them to sign in again for a fresh one.

Step-up & admin reset

Two operational behaviors round out the flow:

In-session step-up: sensitive operations re-challenge for a factor even within an active session: removing a factor, regenerating recovery codes, and adding another factor once one is held. On the Direct API they take a step-up token from POST /v1/identity/auth/mfa/step-up in the x-mfa-step-up-token header; the hosted security page asks for the proof on its own.Admin force-reset: an admin can clear an identity's enrolled factors to recover a lost-device lockout. The next sign-in then follows the Environment's policy as for someone who never enrolled: where MFA is required, enrolment now, or a fresh grace period when one is set. An organization's own administrator can do the same for a member of that organization, from the portal or POST /api/v1/organizations/{id}/members/{identity_id}/mfa/reset, while that member belongs to no other organization and signs in to no other Environment; otherwise the request answers 409 organization.member_mfa_shared and the reset is yours to make.

Remember this device

Off until you give it a window

A challenge answer can carry remember_device: true, and the device then skips the second factor on later sign-ins to the same Environment. The Environment decides whether that is honoured: mfa_trusted_device_days is 0 by default, which remembers nothing, and is raised in the Console under Tenant → Authentication → Multi-Factor Auth (up to 90 days). Read the value from GET /v1/identity/auth/config before you show a checkbox, and read device_remembered on the answer to know what happened: false means the request was declined, not that it failed. Canopy's hosted challenge page follows the same rule and shows the checkbox only when the window is set.

How the remembered device travels
Browser calling Canopy directly: an httpOnly ca_identity_mfa_trusted_device cookie, which the browser attaches to the next /login, /login/email-otp/verify or /sso/authenticate on its own. Nothing to store.Backend-for-frontend: the server that holds your secret key never receives Canopy's cookies, so the answer carries trusted_device_token and trusted_device_expires_at in the body beside refresh_token. Store the token the way you store the refresh token, against this person and this browser, and send it back as trusted_device_token in the body of their next sign-in. A body token from a request without the secret key is ignored.The person sees their remembered devices on the self-service security page and at GET /v1/identity/auth/mfa/trusted-devices, and can revoke any of them. An administrator's MFA reset revokes them too: one made from the Admin Workspace revokes every remembered device in every Environment, and one made through the API or by an organization's administrator revokes the devices remembered in that Environment.
POST /v1/identity/auth/mfa/challenge/totp
X-API-Key: cnpy_...
{ "challenge_token": "...", "code": "123456", "remember_device": true }

200
{
  "data": {
    "access_token": "...",
    "refresh_token": "...",
    "device_remembered": true,
    "trusted_device_token": "...",
    "trusted_device_expires_at": "2026-10-31T00:00:00.000Z"
  }
}

POST /v1/identity/auth/login
X-API-Key: cnpy_...
{ "publishable_key": "...", "email": "...", "password": "...", "trusted_device_token": "..." }

Passkeys and your domain

A passkey only works on the domain it was created for

WebAuthn scopes every credential to a relying-party id: a registrable domain such as acme.com. The browser refuses a ceremony unless the page's domain is that id or a subdomain of it, and Canopy refuses an answer from any origin it has not been told about. So where a passkey can be used is decided by where it was enrolled, and you have to tell Canopy which domain that is.

Two surfaces, two relying parties
Canopy's hosted pages (the hosted OAuth login, the MFA challenge and enrolment pages, the self-service security page) run on Canopy's own domain, so passkeys made there are bound to Canopy's relying party, or, once the Environment has a custom auth domain, on that domain, bound to it. This needs no configuration.Your own app, driving the identity WebAuthn endpoints (/v1/identity/auth/mfa/webauthn/*, /v1/identity/auth/mfa/challenge/webauthn/*, /v1/identity/auth/mfa/step-up/webauthn/*) from your own domain, whether the browser calls Canopy directly or through your backend. Until the Environment names your domain, every one of these ceremonies is refused by the browser, because the options Canopy issues name Canopy's relying party.
Name your domain on the Environment

Two fields on the Environment's auth settings, editable in the Console under Tenant → Authentication → Multi-Factor Auth (Passkey relying party) or through the portal API:

webauthn_rp_id: the registrable domain your app runs on, with no scheme or port (acme.com, which also covers portal.acme.com; localhost in development).webauthn_origins: the exact origins allowed to run ceremonies, scheme and host and optional port (https://portal.acme.com, http://localhost:3000). Every host must be the id or a subdomain of it. At least one is required when the id is set.Both, or neither. Set the pair to use your relying party; set webauthn_rp_id to null to go back to Canopy's. Origins alone, an id alone, or an origin outside the id are refused with 400 and a mfa.webauthn_* error code.
PATCH /portal/v1/accounts/{accountSlug}/applications/{appSlug}/environments/{envSlug}/auth-settings
{
  "mfa": {
    "mfa_factor_allowlist": ["totp", "webauthn"],
    "webauthn_rp_id": "acme.com",
    "webauthn_origins": ["https://portal.acme.com", "https://acme.com"]
  }
}
What follows from the binding
Every passkey records the relying party it was created under; GET /v1/identity/auth/mfa/factors returns it as webauthn_rp_id. A challenge offers webauthn in available_factors only when the identity holds a passkey bound to the relying party of the surface they are signing in on.A passkey enrolled on Canopy's hosted pages does not work in your own app once the Environment names its own relying party, and one enrolled in your app never works on the hosted pages. The other factors (authenticator app, email code, recovery codes) are unaffected and work everywhere.Changing webauthn_rp_id orphans every passkey enrolled under the previous value. Those identities still sign in with their other factors and can enrol a new passkey; the old ones are simply never offered again.Passkeys enrolled before an Environment named a relying party are bound to Canopy's. They keep working on the hosted pages, and in your app only while the Environment still uses Canopy's relying party.

Where MFA applies

MFA covers both the Direct API and Hosted OAuth

The hosted login pages run the same challenge and enrollment flow as the Direct API, and the same organization policy (its MFA tightening, Require single sign-on and sign-in methods), so your users are covered however they sign in. Tokens issued after a factor is verified record it in the standard amr (authentication methods reference) claim: for example ["pwd", "mfa"] for a password login plus a second factor, or ["sso", "mfa"] for an SSO step-up, so your backend can confirm a session was MFA-verified before allowing a sensitive action.

Per-organization policy

Organizations tighten the Environment, never loosen it

With the organizations container on, each organization carries its own policy over the Environment's: mfa_required, mfa_factor_allowlist, and mfa_after_sso, each nullable and inheriting the Environment when unset, plus require_sso. The effective policy is the Environment's tightened by the organization's, decided at login for the organization the session starts in, on the Direct API and the hosted pages alike, and again at every organization switch, which can raise an MFA challenge of its own. A write that would loosen the Environment is refused. It exists so one customer can mandate MFA, or SSO only, while another keeps the Environment's default: the customer's own admin decides in your product, and your backend writes the organization's policy through the API.

Organizations: per-organization authentication policy
Environment
API version
v1.0
On this page Was this page helpful?

Tell us how we can improve this guide.