Passkeys replace passwords with the fingerprint, face or device PIN your users already use to unlock their phone. There is nothing to remember, nothing to reset, and — because the private key never leaves the device — nothing in your database worth stealing.
Canner Auth gives you that without the usual cost of running it. You get an API; we hold the credentials. Your backend never queries a users table, because there isn’t one: it verifies a short-lived signed token against a public key, the same way it would verify any JWT.
How it fits together
A Canner Auth app is the unit of configuration. It belongs to your Canner account, is tied to one domain, and issues two keys:
- a publishable key (
cnr_auth_pk_…) that goes in your client-side code. It is not a secret — it only works from the origins you allowlist, so publishing it costs you nothing. - a secret key (
cnr_auth_sk_…) for your backend, on paid plans. It manages end users and mints enrollment tickets, and must never reach a browser.
Before you start
Create one at Identity → Passkey. Two things have to be true about your Canner account first: your email is verified, and you have a passkey on the account itself. That second one is deliberate — the product can send email to your users, and we only hand that capability to accounts that have proven a real device.
Then declare the origins your sign-in page is served from — the same idea as an OAuth redirect URI list. Your app does not have to run on Canner: put in https://app.example.com and it works wherever that is hosted. Nothing to verify, no DNS to change.
From those origins we derive the scope — the domain your passkeys are bound to. One origin gives the tightest possible scope: declare https://app.example.com and the credentials work on that subdomain and nowhere else. List several and the scope widens to their common parent, because a passkey has to be valid on every origin that uses it.
Scope is permanent. It is written into every credential when it is created, so moving an app from one subdomain to a sibling later orphans every passkey enrolled against the old one. Widen deliberately at setup if you expect to move; the create form offers it.
The one exception is your-project.canner.app: apps on our own tenant domain must belong to a project in your organization. We host many customers under that domain and they do not trust each other, so it is the single case where a domain claim is worth checking.
1 — Ask for an email
Sign-up starts with an address. We send a confirmation link and return the same response either way, so your sign-up form can never be used to work out which addresses have accounts.
// 1. Ask for an email. We send a confirmation link.
await fetch("https://api.canner.ca/v1/auth/register/start", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.NEXT_PUBLIC_CANNER_AUTH_PUBLISHABLE_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({ email }),
});
// The response is always { ok: true } — it never reveals whether the
// address already has an account.2 — Bind a passkey
The emailed link lands back on your page with a one-time token in the query string. That is not a detail we could change: a passkey can only be created on your own domain, so this step has to run there. Set the landing page under Email link lands on — any URL on one of your allowed origins.
import { startRegistration } from "@simplewebauthn/browser";
// 2. The user clicks the emailed link and lands back on YOUR page with
// ?canner_auth_token=… in the URL. Bind the passkey here.
const token = new URLSearchParams(location.search).get("canner_auth_token");
const { options, challenge_id } = await api("/v1/auth/register/options", { token });
const attestation = await startRegistration({ optionsJSON: options });
const { token: jwt } = await api("/v1/auth/register/verify", {
token, challenge_id, response: attestation,
});3 — Sign in
Sign-in needs no identifier at all. The browser offers whichever passkey it holds for your domain, and the assertion tells us who it belongs to.
import { startAuthentication } from "@simplewebauthn/browser";
// No email, no password, no identifier — the browser offers whichever
// passkey it holds for your domain.
const { options, challenge_id } = await api("/v1/auth/login/options", {});
const assertion = await startAuthentication({ optionsJSON: options });
const { token, expires_in, user_id } = await api("/v1/auth/login/verify", {
challenge_id, response: assertion,
});4 — Verify the token in your backend
This is where “no database” becomes real. The token is a standard ES256 JWT; verify it with any JWT library against the public JWKS. No call to us, no user lookup, no session table.
import { createRemoteJWKSet, jwtVerify } from "jose";
// Injected into your project automatically when the app is attached.
const JWKS = createRemoteJWKSet(new URL(process.env.CANNER_AUTH_JWKS_URL!));
export async function currentUser(req: Request) {
const token = req.headers.get("authorization")?.replace("Bearer ", "");
if (!token) return null;
const { payload } = await jwtVerify(token, JWKS, {
issuer: process.env.CANNER_AUTH_ISSUER,
audience: process.env.CANNER_AUTH_APP_ID,
});
return { id: payload.sub, email: payload.email };
}Your app needs four identifiers to do that. Attach the Canner Auth app to a project and they land in its environment automatically on the next deploy — no copying between dashboards:
CANNER_AUTH_APP_ID=… CANNER_AUTH_PUBLISHABLE_KEY=cnr_auth_pk_… CANNER_AUTH_ISSUER=https://auth.canner.app/apps/… CANNER_AUTH_JWKS_URL=https://auth.canner.app/apps/…/.well-known/jwks.json
Attaching is optional and reversible, and it is only about delivery. If your app runs somewhere else, or you would rather manage its configuration yourself, leave the app unattached and set the same four values by hand — nothing about sign-in behaves differently.
The secret key is never injected either way, because build environment variables reach your client bundle in most frameworks.
Headless apps and opaque users
If your users are not identified by email, skip the email step entirely. Your backend calls /v1/auth/tickets with the secret key and its own external_id, then hands the returned ticket to your frontend to enroll with. Your app decides who is allowed to enroll, which is the right place for that decision — a publishable key alone can never bind a passkey to an identifier.
An app set up this way sends no email at all, which is the best possible outcome for everyone’s deliverability.
On the free plan Canner sends exactly three messages, from auth.canner.app: confirm an address, confirm a new passkey, recover a lost one. There is nothing else, and the templates are fixed.
Every plan can set the sender name— the name in the From line and at the head of the message. It is separate from the app name, because the app name is your label (“macapsule-prod”) and this is what your user recognises. Canner’s logo never appears on these messages; if you set no name we fall back to the app name, and if you set no logo the message simply has none.
Every plan can also choose the language — English or French — under Identity → Passkey, then Configure on the app. Pass localeon the sign-in call to set it per end user; without it the app’s own setting is used. Localisation is not a paid feature: the wording is still ours, just in the reader’s language.
Paid plans can add a logo and a support address, and can send from their own verified domain— add the domain, publish the DNS records shown, and the sender becomes yours. If the domain is registered with Canner, you don’t have to publish anything by hand — we host its DNS, so the page offers to add the records for you. If we say the domain needs verifying first, add the _canner-auth TXT record shown and press Verify; once it resolves, the domain works.
Custom wording needs that verified domain. On auth.canner.appthe subject and body stay ours, because that address is shared by every app on the platform and its deliverability is not one customer’s to spend. Once your own domain signs the mail, the reputation being spent is yours and you can write the subject, body, button and footer for each message, in each language, leaving anything blank to keep ours.
Recovery deliberately replaces rather than adds: if someone has lost every passkey, the old ones are unreachable anyway, and leaving them enrolled would mean a stolen device still worked after the owner recovered.
Settings
Paid plans can change five defaults. All are visible on every plan, and every one works as-is.
| Setting | Default | Range |
|---|---|---|
| Token lifetime | 15 min | 5 min – 24 h |
| Sign-in method | Any | Any · this device · phone or security key |
| Require a PIN or biometric | Off | Off · always |
| Sign-in timeout | 60 s | 30 – 300 s |
| Max passkeys per user | 5 | 1 – 10 |
Two caveats worth knowing. There is no way to revoke a token early, so the lifetime is how long a stolen one keeps working. And “phone or security key” is what shows your users the cross-device QR code.
Plans
The sign-in API is available on every plan, including Starter — a free project can run real passkey auth, with 1,000 monthly active users. Live raises that to 125,000 and adds the management API, the settings above, a higher email allowance, and a custom sending domain. Studio is uncapped. Full table: plans & limits.
The monthly-active-user cap applies to new sign-ups only. Reaching it stops enrolment; everyone already using your app keeps signing in.