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. Three things have to be true first: your account email is verified, you have a live project, and you have a passkey on your own Canner account. That last 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.
Your app’s domain must be one you control: either your-project.canner.app, or a custom domain you have already verified. This is checked on every request, not just at setup.
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 the ceremony has to run there. Set the landing page when you create the app.
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, ceremony_id } = await api("/v1/auth/register/options", { token });
const attestation = await startRegistration({ optionsJSON: options });
const { token: jwt } = await api("/v1/auth/register/verify", {
token, ceremony_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, ceremony_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", {
ceremony_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 };
}Attach the Canner Auth app to a project and these land in its environment automatically on the next deploy — no copying identifiers 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
The secret key is never injected, 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, with your app’s name in the sender: confirm an address, confirm a new passkey, and recover a lost one. There is nothing else, and the templates are fixed. Paid plans can send from their own verified domain and customise the copy.
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.
Plans
The sign-in API is available on every plan, including Starter — a free project can run real passkey auth. Paid plans add the management API, more monthly active users, higher email limits, multiple apps, and a custom sending domain.