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. The steps, and what to do if it will not verify, are under Send from your own domain below.
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.
Send from your own domain
On a paid plan the sign-in emails can come from your domain instead of auth.canner.app: the mail is signed by your domain, lands on your reputation rather than a shared one, and unlocks custom wording. It takes a few DNS records and, usually, a few minutes.
- Add the domain.Under Identity → Passkey, open the app and go to Email. Enter the domain as
example.com, with nohttps://and no path. A subdomain such asauth.example.comworks as well as the root, and keeps the reputation of this mail apart from your everyday mail. - Copy the records we show. You will see a short table of DNS records, with a Copy button on each name and value. There is usually one
TXTrecord that signs your mail (DKIM), one that authorizes sending (SPF), and oneMXrecord, which is the only one with a priority. The records sit on names of their own, so mail you already receive at your domain is not affected. - Add them where your DNS is managed.That is wherever your domain’s nameservers point: usually your registrar or a DNS host. If the domain is registered with Canner we host its DNS, and the page offers to add the records for you. Otherwise, add each record at your provider:
- Use the type, name, priority and value exactly as shown.
- Some providers add your domain to the end of every name for you. If yours does, don’t type it again; if it asks for the full name, put your domain after the name shown.
- Paste values without quotation marks around them. Long values, like the DKIM key, must not be cut off or split.
- Wait for the check. Each record shows a status: Checking means it has not been seen yet, and Not found means the mail provider looked and it did not match. DNS changes usually show up within minutes and can take a few hours.
You do not have to keep checking. Canner re-checks on its own, every few minutes for the first half hour and then about three times an hour, for 24 hours. If it finds the records it emails you to say the domain is verified. If it cannot find them all by then, it stops and emails you once, listing the records it could not find. Nothing breaks in the meantime: sign-in emails keep going out from auth.canner.app until your domain is verified. Press Check again at any time to check right away; after Canner has stopped, it also starts another 24 hours.
Once verified, the sender reads noreply@your-domain. You can change the part before the @ and write your own wording for each message.
Change or remove the domain
Remove domain takes the domain off the app. Sign-in emails go back to auth.canner.app and custom wording stops being used (it is kept, and applies again if you verify a domain later). To switch to a different domain, remove the current one and add the new one. Removing does not touch your DNS: the records you added at your provider stay there until you delete them.
A setup that was never finished is released completely, so the name is free to use again. A verified domain stays registered with our mail provider after you remove it; to use it again, add it back, and if we say it needs verifying first, add the _canner-auth TXT record shown and press Verify.
If it will not verify
| What you see | What to check |
|---|---|
| Every record says Not found | The records are probably at the wrong DNS provider. Look up where your domain’s nameservers point (your registrar will show it) and add the records there. |
| One record says Not found | A typo, your domain typed twice (name.example.com.example.com), quotation marks around the value, a cut-off value, or a missing MX priority. |
| Checking for hours | Usually just DNS being slow. Wait; Canner keeps checking for 24 hours. |
| “You’ve checked a lot in the last hour” | Checks are limited to 20 an hour per app, and adding a domain to 10 an hour. Make your changes, wait a few minutes, then check again. |
| “Needs to be verified before it can send” | The name is already registered with our mail provider. Add the _canner-auth TXT record shown and press Verify. |
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. Enterprise 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.