Preventing Duplicate Credential Registration
excludeCredentials is the only mechanism that stops an authenticator creating a second credential for an account it already holds one for, and it is a server-side construction with no client input. This page covers where duplicates come from, how to build the list correctly, and why the resulting InvalidStateError is the success case rather than a failure. It sits under Designing Secure Registration Endpoints.
Behaviour Reference
| Situation | Authenticator behaviour | What you should do |
|---|---|---|
| No exclude list sent | creates a second credential silently | send the list |
| Match found in the list | asks for a touch, then refuses | show “already enrolled”, HTTP 200 |
| List contains a revoked credential | refuses re-enrolment after a device reset | drop revoked rows from the list |
| List built from another account | refuses a legitimate first enrolment | scope the query to the enrolling user |
The middle two rows are the ones that produce support tickets, and they are opposite failures of the same query.
Root Cause Analysis
1. The list is not sent at all. Frequently because the registration options are assembled by a helper that was written for the first-enrolment case. The result is an account that accumulates a credential per attempt, each of which then has to appear in every future list.
2. The query is not scoped to the enrolling user. On a shared device this refuses a legitimate registration, because someone else’s credential for your relying party is present on the authenticator. It is a real production failure mode on kiosks and family machines.
3. Revoked rows are left in. A user who factory-resets a security key to reclaim storage then cannot re-enrol it, because your list still names a credential the device no longer has. The refusal is indistinguishable, from the browser, from a genuine duplicate.
4. Transports are dropped. Without them the client cannot route the exclusion check efficiently, which on cross-device flows is the difference between an immediate answer and a scan that never completes.
Step-by-Step Resolution
Step 1 — derive the user from the session, never from the request body
The account being enrolled is the authenticated one. A user identifier supplied by the client is an invitation to enumerate.
Step 2 — select live credentials only
Filter on the revocation column in the query rather than in application code, so a new code path cannot forget it.
Step 3 — include transports as stored
Copy the values recorded at the original registration. Do not infer them.
Step 4 — bound the list
An account with many credentials produces a large request, and constrained transports fragment it. Send the entries plausibly relevant to the current platform when the list grows beyond a handful.
Step 5 — handle the refusal as an outcome
Catch InvalidStateError, return a success-shaped response, and tell the user their device already has a passkey. Never retry.
Verification and Testing
Drive two registrations against the same virtual authenticator in the same test. The first must succeed; the second must be refused. That single test covers list construction, transmission and error handling together, which no unit test of the query alone will do.
Add a test that revokes the first credential and then re-registers. It must succeed — and it will not if revoked rows are still in the list, which is the regression this test exists to catch.
Add a test with two accounts on one authenticator. The second account’s first registration must succeed, proving the query is scoped.
Pitfalls
1. Treating the refusal as an error. It is the mechanism working; a 4xx response sends the front end down the wrong path.
2. Building the list from a client-supplied identifier. Turns registration into an enumeration oracle.
3. Letting the list grow without bound. Large requests fail on constrained transports before any policy applies.
Frequently Asked Questions
Does the exclude list leak which credentials an account has?
It discloses credential identifiers to whoever is already authenticated as that account, which is the account holder. It is not disclosed before authentication, and it is not disclosed to another account, provided the query is scoped to the session. That is the whole reason the scoping matters.
Why does the authenticator ask for a touch before refusing?
Because revealing whether an excluded credential is present without user involvement would let a page probe an authenticator’s contents. The touch is the privacy boundary; the refusal comes after it, which is why the user experience is identical right up to the moment it fails.
Should I pre-check for duplicates server-side and skip the ceremony?
It improves the experience — if the account already has a credential whose transports match the current platform, offering “you already have a passkey on this device” is friendlier than a refused ceremony. Keep the exclude list as well: the server-side check is a heuristic from your records, and the authenticator’s check is authoritative for the device in hand.
What if the user genuinely wants two passkeys on one device?
They almost never do, and it provides no resilience — losing the device loses both. Where a deliberate second credential is required, for instance to move from a non-resident to a resident one, that flow should exclude the old credential explicitly rather than sending no list at all.
Does this apply to platform authenticators?
Yes, and the effect is more visible: without an exclude list, a user who re-runs the enrolment flow twice ends up with two synced passkeys that both work, both appear in the platform’s account chooser, and both appear in your settings page. It looks like a bug in your product because, in effect, it is.
How large can the list get before it causes problems?
There is no single number, but constrained transports fragment large messages and some authenticators limit how many entries they will evaluate. A handful is unremarkable; dozens is a signal that revocation is not keeping pace with enrolment and that your account settings page needs attention.
What should the account settings page do about duplicates that already exist?
Show them, with their creation dates, and make removal easy. Accounts that accumulated duplicates before the exclude list was fixed will keep carrying them into every future ceremony, and the only way they leave is if a user removes them. Labelling each row with the date it was added and when it was last used gives the user enough to recognise the stale one, and an archival sweep over credentials unused for a long period will clear the rest without anybody having to think about it.
The same page is where a user discovers that a credential they thought they had removed is still listed, which is usually a sign that revocation updated one store and not another — worth checking whenever duplicates and stale entries appear together.
Does the exclude list need to be sent on every registration, or only the first?
On every one. The first registration is the only ceremony where the list is legitimately empty, and even then sending an empty array costs nothing and keeps the code path uniform. Every subsequent enrolment — a second device, a replacement after a loss, an upgrade from a server-side credential to a resident one — needs the list, and a code path that treats the list as an optimisation for accounts with many credentials will eventually skip it for an account with one.
Related
- Designing Secure Registration Endpoints — the parent topic area
- Fixing InvalidStateError on Registration — handling the refusal on the client
- Revoking a Lost Passkey — keeping revoked rows out of the list
- Credential Indexing and Database Schema Design — the query that produces the list