Two-factor Authentication
Emmie requires two-factor authentication (or 2FA) at every login, for every role: platform admins, employees (users) and clients (EMMIE-0808, "Every role needs a second factor at every login"). The organisation no longer has a setting for it.
Setting up 2FA
The current flow for a user setting up 2FA is as follows:
- A user chooses to set up two-factor authentication
- A modal shows up:
Register2FALoggedInUser.vue, which opens straight on the authenticator app. The authenticator app is the only method this modal sets up; YubiKey support was removed in EMMIE-0810. Passkeys are below.
- A private key is automatically generated (or when one is already present from an earlier attempt, it will be fetched here) and saved in
user_key_setupsin the database. These keys are valid for 30 minutes before automatically being removed.
A QR code appears in the modal. The user scans this code with the authenticator app on their phone, and copies the provided code from their phone to the input on screen.
If the code entered is correct, 2FA will be successfully set up. The next time the user logs in, they will need to enter a code from their authenticator app to continue logging in. The authentication keys are stored in
user_keysin the database.
Whenever a principal logs in, CheckTwoFactorAuthenticationAction looks for an active key. The check reads no setting. A principal without an active key gets {requiresOtp: true, hasKeys: false} and the frontend shows the setup step above, before any session exists: the enrolment is the login. In a browser with passkeys that step first offers a choice, the app or a passkey (below, EMMIE-2221 and EMMIE-2116). A principal with an active app gets {requiresOtp: true, hasKeys: true} and enters a code from the authenticator app. A principal whose only active keys are passkeys has no app to give a code, so it gets a 422 with "Je logt in met een passkey. Gebruik de knop Inloggen met passkey." instead, after a correct password and the login's other refusals, and nothing of the code budget below is spent (EMMIE-2114, J4).
Two kinds of session carry no code, because nobody logs in there (EMMIE-0808, "Impersonation and persona links stay without a code"):
- a platform admin's impersonation of an employee or client, after the admin passed their own second factor;
- the demo and proeftuin persona links.
Client 2FA
For clients the flow of setting up 2FA is the same, but with slightly different component names. The private keys are stored in client_key_setups and the connected authentication keys in client_keys. A client without a key who asks for a day-update token through create-day-update-token gets the same hasKeys: false answer as at the login (EMMIE-0808, "The day-update token asks a keyless client to set up a key"). The external day-update app takes no passkey, so a client whose only active keys are passkeys gets a 422 with "Voor deze app heb je de authenticatie-app nodig. Stel die in bij je instellingen." there (EMMIE-2114, J10).
Code limit
Every submitted code counts, right or wrong: 10 per 15 minutes per account, keyed on customer, guard and account id, never on the address. The login, create-day-update-token and profile/private-key share that budget. Over the limit the answer is a 429 (TooManyTwoFactorCodesException), which every frontend shows as a Dutch "try again later" sentence (EMMIE-0808, "Ten codes per account per fifteen minutes").
Recovery
There are no recovery codes, no trusted devices, and no way to switch off your own second factor. A lost authenticator is recovered by a reset by someone else (EMMIE-0811):
- an employee or client: a colleague with the right
two-factor-reset("Tweestapsauthenticatie resetten", held by the role Beheerder by default), or platform admin, who can reset only an employee that holds that right; - a platform admin: another platform admin, or
php artisan admin:2fa:reset {email} --reason=….
Nobody resets themselves. A reset deletes the keys, passkeys included, and pending setups, ends the person's sessions in the same transaction (EMMIE-2104), writes 2fa.key.deleted and mails the person. The person then enrols a new app at the next login.
Passkeys (developers)
The user manual for every role is user-documentation/auth/passkeys.md (EMMIE-2118). A UI sentence that changes here changes there too: the manual quotes it.
A passkey is a key of key_type 3 (TwoFAKeyTypeEnum::PASSKEY) in the role's key table: user_keys, client_keys or admin_keys (EMMIE-2110). Its credential lives in columns of its own (credential_id, public_key, sign_count, transports, last_used_at); key stays empty and preferred stays false. The account keeps a random WebAuthn user handle in passkey_user_handle, made at its first passkey and never cleared.
Every role adds one through the same two routes in routes/auth.php, signed in:
POST profile/passkey/options, withotpwhen the account has an active app. The answer is the options JSON thatstartRegistration({optionsJSON})of@simplewebauthn/browsertakes. Its challenge is valid once, for five minutes.POST profile/passkey, with{credential: <the RegistrationResponseJSON>}. The passkey is stored at once, named after the browser's platform ("Passkey op Mac"), and the answer is{id, name, createdAt, lastUsedAt, deletable}with status 201.
Confirming with a passkey
A new key, a passkey or an app, first confirms the current one. With an active app that is a code from it, also beside a passkey. An account whose only active keys are passkeys confirms with one of them instead (EMMIE-2114):
POST profile/passkey/optionsorPOST profile/private-keywithout a passkey's answer gets a 422{requiresPasskey: true}.POST profile/passkey/confirmation/optionsanswers the options JSON forstartAuthentication({optionsJSON}). They list the account's own active passkeys, and the challenge is bound to the account.- The first request again, with
{credential: <the AuthenticationResponseJSON>}. The confirmation is recorded as a passkey login:last_used_atand a2fa.verify.succeededline.
Every refusal of the answer gets "Bevestigen met deze passkey is niet gelukt. Probeer het opnieuw.", also a passkey of another account. A platform admin who impersonates someone gets a 403 on the confirmation options.
A passkey added right after the login needs no confirmation: the login itself was one (EMMIE-2317, Bert 2026-10-15). Every login token carries sfa, the moment its login passed the second factor (SetLoginTokenClaimsAction), and profile/passkey/options skips the confirmation for ten minutes after it (IsSecondFactorRecentAction). sfa is a persistent claim, so a refresh keeps it and the ten minutes never restart; a token reissued after a revoke, or an impersonation token, carries none. Linking an app keeps its confirmation.
App\Services\Passkey\PasskeyCeremony builds the options and checks the answer: the RP ID, the exact origin of the request, the challenge, and user verification. A refused answer gets a 422 with one sentence and a 2fa.verify.failed log line with key_type 3. A platform admin who impersonates someone gets a 403.
The first key at the login
An account without an active key may enrol a passkey as its first key during the login, as it enrols the app there (EMMIE-2221). Two more routes in routes/auth.php, without a session and under the login's auth throttle:
POST passkey/options, with{email, password}. The answer is the options JSON forstartRegistration({optionsJSON}), under a challenge bound to the account. A wrong password and an account with an active key get the same wrong-password answer, before any challenge.POST passkey, with{email, password, rememberMe, credential: <the RegistrationResponseJSON>}. This request is the login: its refusals come first, then the passkey is checked and stored, and the answer is the password login's. That is the account with its cookie, or a 403 with a reset token when an employee's periodic password change is due.
A refused answer gets "Deze passkey kon niet worden toegevoegd. Probeer het opnieuw." and a 2fa.verify.failed line; a stored passkey writes 2fa.key.created with key_type 3. Afterwards the account's only key is a passkey, so the password login points at the passkey button. An invitation's activation sets the password only, so the login after it can enrol a passkey the same way.
The setup screen
The mandatory setup (apps/auth/components/TwoFactorSetup.vue) serves the login page and the activation page of the three apps (EMMIE-2116). In a browser with passkeys it opens on a choice: "Authenticatie-app" or "Passkey". In a browser without passkeys it opens on the QR code, as before. The signed-in window that links another app offers no choice.
- "Authenticatie-app" shows the QR code. "Terug" goes back to the choice, and from the choice back to the login form.
- "Passkey" asks
POST passkey/optionswith the e-mail address and password the setup holds. The next step says "Voeg alleen een passkey toe op een apparaat dat alleen jij gebruikt." and "Passkey maken" opens the browser's prompt. - The answer goes to
POST passkeythrough the auth service'sregisterPasskeyAndLogin, with "Op dit apparaat ingelogd blijven". The login's own handling follows: a 403 with a reset token goes to the reset page, as on the app path. - After the login the name step (
PasskeyNameStep.vue) readsGET profile/passkeyand offers the name of the account's only passkey. A changed name is saved throughPATCH profile/passkey/{id}. "Overslaan", a failed save, or a list that does not hold exactly one passkey keeps the suggested name. Then the person lands as after any login. - A prompt the person closes brings the choice back, with no error. A refusal of the options or a browser error keeps the choice, with "De passkey kon niet worden toegevoegd. Probeer het opnieuw."; the backend's reason shows as a toast.
- A second click while the options or the prompt run does nothing. Leaving the page ends the prompt.
The screens for adding one
One block and one window serve every role (EMMIE-2111). apps/auth/components/PasskeyBlock.vue shows the button "Passkey toevoegen" beside the 2FA in each app:
- the employee: the own profile (
User2FA.vue), not a colleague's; - the client: Instellingen (
Manage2FA.vue); - the platform admin: the own panel under Beheerders (
Admin2FA.vue), not another admin's.
A browser without WebAuthn shows no block. The button opens PasskeyAddModal.vue, which says "Voeg alleen een passkey toe op een apparaat dat alleen jij gebruikt." and takes three steps:
- The confirmation: the code from the app, or for an account without an app "Bevestigen met passkey", the flow above.
- "Passkey maken" opens the browser's prompt. The passkey is stored right after it.
- The name, filled with the suggested name. A changed name is saved through
PATCH profile/passkey/{id}; an unchanged one sends nothing. Then "Passkey toegevoegd." and the window closes. This step, the setup's name step and the rename window share one form,PasskeyNameForm.vue.
A prompt the person closes is no error: the window stays on the step and shows nothing. A device that already holds one of the account's passkeys gets "Deze passkey staat al in je account.". Closing the window at the name step keeps the passkey under its suggested name. A refused passkey starts over at the confirmation, with the reason in a toast.
Linking an app runs the same confirmation: an account whose only active keys are passkeys confirms with one of them in TwoFactorSetup.vue before the QR code shows. Both windows use apps/auth/components/PasskeyConfirmation.vue.
The client's list in Instellingen shows only the apps. A passkey still counts as an active key there, so the last app can go while a passkey remains.
apps/auth/passkey.ts is the one place that calls @simplewebauthn/browser. It sends a passkey's answer as a JSON string, because each app's request middleware turns object keys into snake case and the backend reads the browser's own keys (rawId, clientDataJSON).
The passkey list
Every role manages its own passkeys through three more routes in routes/auth.php, signed in (EMMIE-2219):
GET profile/passkey: the account's passkeys, oldest first, each{id, name, createdAt, lastUsedAt, deletable}.deletableis false for the account's only active key, which the delete refuses (EMMIE-2115). The list and the delete share one rule,RefuseLastActiveKeyAction::isOnlyActiveKey().PATCH profile/passkey/{id}, with{name}: a new name, at most 191 characters and not empty. The name step after adding a passkey saves through this route too.DELETE profile/passkey/{id}: deletes the passkey and signs out every other device of the account. This device stays signed in: the answer carries a new session cookie and the message "Passkey verwijderd. Je bent op je andere apparaten uitgelogd." The answer comes up to one second later, because the new token must be issued after the second of the sign-out. A passkey that is the account's only active key is refused with a 403 that says why. The delete writes2fa.key.deletedwithkey_type3.
An id of another account's passkey, or of an app, answers 404. A platform admin who impersonates someone may read the list, but a rename or delete gets a 403.
The passkey block shows this list in each app, below its sentence and above "Passkey toevoegen" (EMMIE-2115):
- One row per passkey: the name,
Toegevoegd op <datum>, andLaatst gebruikt op <datum>or "Nog niet gebruikt". - Without passkeys: "Je hebt nog geen passkeys." While the list loads, a spinner; when it cannot load, "De passkeys konden niet worden geladen." with "Opnieuw proberen".
- Rename opens
PasskeyRenameModal.vue. A refused name keeps the window open, with the reason under the field. The list loads again after a rename. - Delete asks for confirmation first. After the delete: "Passkey verwijderd. Je bent op je andere apparaten uitgelogd." The list loads again, and the parent reads the account again.
- A passkey with
deletablefalse has its delete button switched off, with "Dit is je laatste manier om in te loggen. Stel eerst de authenticatie-app in, dan kun je deze passkey verwijderen." A delete refused with a 403 (another device deleted the app meanwhile, or an impersonated session) shows the backend's message and loads the list again. A 403 on aprofile/passkeyroute never sends the person to the dashboard, as every other 403 does (apps/auth/service.ts). - Only the newest load fills the list: a reload after adding a passkey wins over a first load that answers later.
Logging in
Every role logs in with a passkey through two more routes in routes/auth.php, without a session and without an e-mail address (EMMIE-2112):
POST login/passkey/options. The answer is the options JSON thatstartAuthentication({optionsJSON})takes. It names no passkey: the browser offers every passkey of the RP ID.POST login/passkey, with{credential: <the AuthenticationResponseJSON>, rememberMe: true|false}. The answer's user handle finds the account in this role's table, and the passkey must be one of that account's. The answer is the password login's: the account with its cookie.
A passkey replaces password and code: the login asks no code and skips the expired-password check. A handle this role does not know gets Deze passkey hoort niet bij <organisatie>…, a deleted passkey Deze passkey is verwijderd…, a blocked account or an organisation without a subscription the password login's sentence. A success writes last_used_at and a 2fa.verify.succeeded log line. Both routes share the passkey-login limiter, 30 a minute per address.
The login screen
The one login page of the three apps (apps/auth/pages/Login.vue) shows "Inloggen met passkey" under the password form, in a browser that supports passkeys (EMMIE-2113). A press asks the options, opens the browser's prompt without an e-mail address, and sends the answer with the checkbox "Op dit apparaat ingelogd blijven" as rememberMe. The checkbox shows only where the organisation's login-active setting is on, and never on the central domain, so a platform admin never stays logged in (J12). After the login the person lands where the password login lands, a return link included.
- A closed prompt shows "Geen passkey gevonden?" above the button: log in with password and app and add a passkey afterwards, or, with passkeys only, use the device that holds one (EMMIE-2318). The browser answers a closed prompt and a device without a passkey alike, so the hint covers both. It clears at the next press.
- Any other browser error shows "Inloggen met passkey is niet gelukt. Probeer het opnieuw." above the button.
- The backend's refusals show as a toast, as the password login's do.
- Leaving the page ends the prompt, and a late answer logs nobody in.
The offer after a login
After a login with password and app, Emmie offers a passkey once (EMMIE-2222, B2). The server decides whether the offer is due, and the login answer carries it as passkey_offer:
- It is due when the account has no active passkey, and its last "Niet nu" is thirty days old or older on the server's clock (
IsPasskeyOfferDueAction). FinishLoginActionasks it for the loginsLoginWithCredentialsActionfinishes after the second factor: the password login, the first app at the login and the first passkey at the login. A first passkey is stored by then, so that login offers none, and its name step lands without the offer.- A passkey login always answers
false, and so does every answer that is not a login:meafter a reload, or a platform admin's impersonation.
The browser adds only its own condition, supportsPasskeys(). The offer is a step of the login page, before the landing (EMMIE-2317): PasskeyOfferStep.vue, from Login.vue and from the login after an activation (ActivateAccount.vue). It shows "Sneller inloggen?", "Voeg een passkey toe." with the passkey block's explanation, the button "Nu toevoegen" and the link "Niet nu". It replaced the small window over the app that EMMIE-2222 opened after the landing, so it never stacks on a window the app shows there, such as the user app's welcome.
- "Nu toevoegen" shows the add steps in the login card (
PasskeyAddSteps.vue, the same steps as the profile'sPasskeyAddModal.vue) and stores nothing. Once a passkey exists, the offer does not come back. "Overslaan" lands at once: before the prompt it stores nothing, at the name step the passkey keeps its suggested name, as closing the profile's window does there. - "Niet nu" sends
POST profile/passkey/offer/dismissal. It stores the time inpasskey_offer_dismissed_atonusers,clientsoradmins(DismissPasskeyOfferAction), so the offer stays away on every device of the account. The route refuses an impersonated session with a 403. - When that request fails, the person lands anyway, and the offer may come back at the next login. Nothing retries it.
The Signal API
A passkey deleted in Emmie, also by a reset, stays on the device, which keeps offering it, and the login then fails. After every login and after a delete, Emmie tells the device which of the account's passkeys still hold, with the Signal API (EMMIE-2220, J11). The device then drops the rest. Chrome 132 and Safari 26 have the API; Firefox does not.
GET profile/passkey/accepted, signed in, inroutes/auth.phpfor every role, answers{rpId, userId, allAcceptedCredentialIds}: the RP ID of this request, the account'spasskey_user_handle, and thecredential_idof each of its active passkeys. These are the namesPublicKeyCredential.signalAllAcceptedCredentials()takes.- The list belongs to the account's own handle. A passkey of another organisation shares the RP ID in production (
emmie.nl) but has another handle, so the device leaves it alone (S1). - An account without a handle has never had a passkey. A platform admin who impersonates someone stands at another device. Both get
userId: nulland an empty list, and the frontend signals nothing. signalAcceptedPasskeys()inapps/auth/passkey.tsasks the route and signals.apps/auth/service.tscalls it after the password login, the first enrolment of an app or a passkey, and the passkey login, without waiting for it.PasskeyBlock.vuecalls it after a delete; a refused delete signals nothing.- A browser without the API sends no request. A failed request or a refused signal is swallowed: this is a tidy-up the person never sees.
- Emmie never calls
signalUnknownCredential: an unknown passkey may belong to another organisation (S4).
RP ID per environment
The RP ID is tenancy.tenant_domain, so a passkey keeps working when an organisation gets a new subdomain:
| Environment | Host | RP ID |
|---|---|---|
| prod-customers | <organisation>.emmie.nl, beheer.emmie.nl | emmie.nl |
| prod-script | <organisation>.app.emmie.nl, app.emmie.nl | app.emmie.nl |
| staging | <organisation>.staging.emmie.nl, staging.emmie.nl | staging.emmie.nl |
| demo | <organisation>.demo.emmie.nl, demo.emmie.nl | demo.emmie.nl |
Only the exact address the person is on may answer, never another subdomain under the same RP ID.
Locally
Locally tenancy.tenant_domain is localhost, a name without a dot. Browsers refuse RP ID localhost from script.localhost, so there the RP ID is the request's own host:
| App | Host | RP ID |
|---|---|---|
| user app and client portal | http://script.localhost:8000 (a worker slot: port 8000+N) | script.localhost |
| admin app | http://localhost:8000 | localhost |
Browsers treat localhost and *.localhost as secure, so no HTTPS is needed. A passkey made on one host does not work on the other.
To try it without a phone, use Chrome's virtual authenticator: DevTools → More tools → WebAuthn → "Enable virtual authenticator environment", then add an authenticator with resident keys and user verification. Playwright reaches the same authenticator through CDP (WebAuthn.addVirtualAuthenticator). The admin routes run in the web group, so a hand-written request there sends the XSRF-TOKEN cookie back as the X-XSRF-TOKEN header.
Tests do not need a browser: Tests\Fixtures\Passkey\SoftwareAuthenticator answers the options with a fixed test key, for a registration and for a login.
On local and testing the seed gives that same key as a passkey, named "Testpasskey", to four accounts (EMMIE-2117): [email protected] and [email protected] beside their app, [email protected] in the admin app, and [email protected] with nothing else. The private key is in the repository, so the passkey is a full login: LocalPasskeySeeder and LocalAdminPasskeySeeder refuse every other environment, staging (the dev and demo apps) included. The E2E suite loads it into the virtual authenticator; frontend/tests/e2e/readme.md says how.
Resets
Every reset deletes the passkeys with the other keys, and a client-account revoke deletes the client's passkeys and keeps the app.
Known issues
There is a known issue with 2FA using the Authenticator-app when the system time does not match the used device's time. If you encounter this issue with running Emmie locally, make sure your system time is either set automatically with the correct timezone, or disable automatic date-time and sync the time with your phone manually. Since most phones sync their times automatically, the server time should also be set automatically, although sometimes the system time can be wrong by a few minutes. As long as the system time and the 2FA device time is the same, the problem should go away.