Passkeys and security keys
Why a passkey is the only factor that survives a convincing fake sign-in page, what the browser ceremony actually proves, and what we check on our side.
A passkey is a pair of numbers. One half stays inside your phone, your laptop's secure hardware or a USB security key and is never sent anywhere. The other half sits in our database. To sign in, your device proves it holds the private half by signing a fresh random challenge we generated a moment earlier. There is nothing to type, nothing to read aloud, and nothing an attacker can capture and reuse.
A security key and a passkey are the same technology in different packaging. WebAuthn is the browser standard underneath both. This article explains why it is the only phishing-resistant factor we offer, what we verify on our side, and — because it tells you more than any assurance could — what this code looked like before it worked.
Why it resists phishing when a code does not
Phishing works because a person can be persuaded to hand something over. A password can be typed into a copy of your sign-in page. So can a six-digit code, and an attacker relaying it in real time has thirty seconds to spare. Everything that a human can read and retype is capturable in principle.
A passkey is not readable by the person using it, and — this is the part that does the work — the browser refuses to use it anywhere except the site it was created for. The signed statement your device produces includes the origin the ceremony took place on: the exact scheme and host in the address bar. We compare that origin against a list we configured in advance. A ceremony performed on a lookalike domain produces a signature that names the lookalike domain, and it is rejected. The user does not have to notice anything.
What the ceremony proves
There are two ceremonies. Registration creates the key pair and gives us the public half. Assertion — every subsequent use — proves the private half is still there. Both begin with the server issuing a fresh random challenge that is stored, single-use, and consumed rather than merely read, so the same challenge cannot be answered twice.
- Registration
- Your device generates the pair, keeps the private half, and returns the public half wrapped inside data the authenticator itself signs. We read the credential identifier and the public key from that signed data. We also send a list of the credentials you have already enrolled, so the same authenticator cannot be quietly registered twice and leave you with three identical entries and no idea which one you lost.
- Assertion
- Your device signs the challenge together with the origin and the identifier of the site. We verify the signature against the stored public key, check that the credential belongs to the account the ceremony is for, and check that the counter inside the authenticator has not gone backwards.
Two configuration choices are made on your behalf and are worth knowing. Credentials are created as discoverable, which is what allows signing in without typing a username at all — a non-discoverable credential cannot do that, and usernameless sign-in is most of the reason passkeys are worth building. And user verification, meaning a PIN or a fingerprint on top of the physical touch, is requested but not demanded by default, so a plain security key with no PIN still works as a second factor. A policy rule that needs verification asks for it per ceremony.
What this code looked like before it worked
We publish this because it tells you more about how seriously to take the current implementation than a paragraph of reassurance would.
In an earlier state of the platform, the assertion step verified nothing. Presenting a known credential identifier completed the ceremony. There was no signature check at all — the thing the entire mechanism exists to do was absent. Registration was worse in a quieter way: the public key was taken from a field in the client-supplied JSON rather than from the data the authenticator had signed, so a caller could have registered a public key it did not hold.
The reason nobody had noticed is the most instructive part. Credential rows were being written with identifiers in the wrong encoding for the column type, so no ceremony had ever completed against a real database. The feature had never once run end to end. A verification step that is never reached does not fail; it simply sits there looking like a feature. That is the failure mode this platform records as its most common defect, and it is why our own documentation now marks capabilities as wired or unwired rather than as present.
Both defects are fixed. The public key and the credential identifier are now read from the signed attested data only, so a caller cannot register a key it does not hold. The signature is verified against the stored key on every assertion. The fix is covered by adversarial tests rather than happy-path ones: a forged signature, a ceremony performed on a phished origin, and a replayed challenge are each rejected, and those tests run against a real database rather than a mock.
What is verified on our side
Every item below is checked server-side on every ceremony. A client cannot skip one by omitting a field, because a missing field is itself a failure.
- The challenge is ours, current, and unused. It is consumed at the start of the ceremony, so a replay fails immediately rather than at the end. The comparison is done on the decoded bytes rather than re-encoded text, because base64 padding differences make text comparison subtly wrong here.
- The ceremony is the right kind. A challenge issued for registration cannot be answered with an assertion, and the type inside the signed client data must match the one we expected.
- The origin is on the allow-list, and the rejection names the list it was compared against.
- The ceremony was not performed inside another site's frame. A cross-origin ceremony is refused, because that is the shape of a clickjacked enrolment.
- The site identifier hashed into the signed data matches, and it is pinned per credential — a credential registered under one identifier cannot be presented under another.
- Somebody was physically present, and where a policy requires it, verified by PIN or biometric.
- The credential belongs to the subject of the ceremony. For a step-up, it must be the signed-in user's own credential. For usernameless sign-in, the handle the authenticator returns is compared in constant time against the one stored at registration — skipping this would let a credential be asserted for whichever account the caller named.
- The signature verifies against the stored public key, over the authenticator data and a hash of the client data.
One more check runs at registration only: the public key is parsed immediately rather than at first use. A key we cannot verify with is a credential that would lock the user out later, and registration is the moment we can still say no.
There is also a clone check. Authenticators may keep a counter that increases with every use; if one that has previously reported a non-zero counter reports a value that is not higher than the last one we stored, the assertion is rejected as a possible cloned authenticator. Many authenticators — most platform passkeys — report zero always, and we accept those. It is honest to say that for those credentials the clone check does nothing; it cannot, because there is no counter to compare.
| What happened | What you will see |
|---|---|
| The ceremony ran on a host that is not on the allow-list — a phishing page, or a misconfigured console | webauthn: clientDataJSON origin is not an allowed origin — followed by the origin that was presented and the list it was compared with |
| The ceremony ran inside a frame on another site | webauthn: ceremony failed: ceremony was performed cross-origin |
| The challenge was replayed, or belonged to a different ceremony | webauthn: clientDataJSON challenge does not match the issued challenge |
| The credential is not one we hold | webauthn: unknown credential |
| The authenticator's counter went backwards | webauthn: sign counter went backwards — possible cloned authenticator |
| The credential was presented for the wrong account | webauthn: ceremony failed: credential belongs to a different user than the challenge |
Origin and site-identifier configuration, and the one way it breaks
The site identifier is derived from the base URL of your Qorionix deployment, and the allowed origin defaults to https:// plus that host. A localhost origin is only ever added when the identifier is literally localhost, so a production deployment cannot pick one up by accident.
Two smaller limits, stated so you do not go looking for them. We do not offer a way to restrict which models of authenticator may be enrolled — any conforming device is accepted. And when a passkey is used to sign in without a username, the organisation is resolved from the first membership on the account, so a person who belongs to more than one organisation cannot currently choose between them at that prompt. Password sign-in carries the same limitation.
A quiet property worth knowing
The endpoint that starts a passkey sign-in names no user and returns an empty list of candidate credentials. That means it cannot be used to find out whether an email address has an account with you — it answers identically either way. Account enumeration is the reconnaissance step before a password-spraying campaign, and closing it costs nothing here because a discoverable credential does not need to be told which account it belongs to.
Rolling passkeys out without locking anyone out
Enrol your own administrator account first
You need a working passkey on the account you would use to fix a mistake. Do this before you touch any policy.
Enrol a second administrator, on a different device
Recovery codes are only issued when somebody enrols a TOTP authenticator app (see Recovery codes) — a passkey or security key on its own generates none. So for a person whose only factor is a passkey, the only route back if they lose the device is another administrator clearing it. One administrator is not a plan. See Multi-factor authentication factors.
Add a spare key for anyone who travels
A second security key kept somewhere safe costs less than an afternoon of lost access. Each authenticator is enrolled separately and can be named, so you can tell the spare from the everyday one in the credential list.
Only then activate the administrator rule
The default policy contains a rule requiring a phishing-resistant factor for administrators, and it ships switched off because activating it before anyone has enrolled locks the organisation out of its own console. Turn it on after the keys exist, not before — Sign-on policies and the decision trace explains the ordering.
Once enrolled, a successful passkey ceremony is recorded in the audit log as mfa.webauthn_success. That record is how you verify, after the fact, that the people you expected to be on passkeys are actually using them — which is a more reliable answer than reading the policy.