---
title: "Two-factor & passkeys — Auth for Laravel | Roundly"
description: "Let signed-in accounts manage TOTP, recovery codes and passkeys — with re-authentication, invalidation, events and notifications."
url: https://roundly-consulting.com/open-source/docs/auth-for-laravel/two-factor-passkeys
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Auth for Laravel](https://roundly-consulting.com/open-source/docs/auth-for-laravel.md)

# Two-factor & passkeys

Setting up factors during login is covered in Login challenges. This page covers a signed-in account managing its own factors. The ceremonies belong to two-factor-for-laravel and passkeys-for-laravel; this package adds guard modes, re-authentication, invalidation, events and notifications.

## Two-factor (TOTP + recovery codes)

Available when the guard’s two\_factor.mode is not off:

| Step | HTTP | Response |
| --- | --- | --- |
| Status | `GET two-factor` | enabled, pending, recovery\_codes\_remaining, mode |
| Start | `POST two-factor` | secret, provisioning\_uri, qr\_svg, recovery\_codes, issuer |
| Confirm | `POST two-factor/confirm` | status enabled + tokens |
| Regenerate recovery codes | `POST two-factor/recovery-codes` | recovery\_codes + tokens |
| Disable (mode optional only) | `DELETE two-factor` | status disabled + tokens |

From PHP, the twoFactor() sub-context runs the same steps — pass the caller’s $current token to keep its device and get the re-issued pair:

```php
use RoundlyConsulting\Auth\Facades\Authentication;

$twoFactor = Authentication::guard('users')->twoFactor();

$status = $twoFactor->status($user);      // enabled, pending, recoveryCodesRemaining, mode
$setup  = $twoFactor->start($user);       // show $setup->qrSvg / ->secret, store ->recoveryCodes
$tokens = $twoFactor->confirm($user, $code, $current, $context);

$codes  = $twoFactor->regenerateRecoveryCodes($user, $current, $context);   // codes, tokens
$tokens = $twoFactor->disable($user, $current, $context);
```

- Starting while already enabled answers two\_factor\_already\_enabled (409); qr\_svg is null when two\_factor.qr.enabled is off or rendering failed.
- A wrong or stale confirmation code answers invalid\_code (422); disabling under required answers two\_factor\_required (409).
- Confirm, regenerate and disable apply invalidation.two\_factor\_changed (default others): every other session ends and the caller gets a fresh pair — swap immediately.
- Events TwoFactorEnabled, TwoFactorDisabled and RecoveryCodesRegenerated; using a recovery code at login fires RecoveryCodeUsed and mails the remaining count.
- An admin disabling it passes themselves as actor: and no $current — Authentication::twoFactor()->disable($user, actor: $admin). The owner’s two\_factor\_disabled mail then says an administrator did it, and TwoFactorDisabled carries the admin as actor. Another actor together with $current throws InvalidArgumentException before anything is written.

## Passkeys

Available when the guard’s passkeys.mode is not off:

| Step | HTTP | Response |
| --- | --- | --- |
| List | `GET passkeys` | Passkeys, newest first |
| Options | `POST passkeys/options` | ceremonyId + publicKey |
| Register | `POST passkeys` | 201 passkey + tokens |
| Rename | `PATCH passkeys/{passkey}` | The passkey |
| Remove | `DELETE passkeys/{passkey}` | status removed + tokens |

From PHP, through the passkeys() sub-context — rename() and remove() take a Passkey or its id, and another account’s passkey is PasskeyNotFound:

```php
use RoundlyConsulting\Auth\Facades\Authentication;

$passkeys = Authentication::guard('users')->passkeys();

$options = $passkeys->registrationOptions($user);                          // ceremonyId + publicKey
$added   = $passkeys->register($user, $response, 'MacBook', $current, $context);   // passkey, tokens

$passkeys->all($user);                                                     // newest first
$passkeys->rename($user, $passkeyId, 'Work laptop');                       // a Passkey or its id
$tokens = $passkeys->remove($user, $passkeyId, $current, $context);
```

- A failed attestation answers passkey\_registration\_failed (422); names are sanitised.
- Removing the last passkey is refused with last\_credential (409) when the account would have no other way in, or the guard requires passkeys. The count and the removal run under a lock on the account row, so of two concurrent removes one succeeds and the other gets last\_credential — never both.
- Register and remove apply invalidation.passkey\_changed (default none, so tokens is null) and fire PasskeyAdded / PasskeyRemoved.
- An admin removing a passkey passes themselves as actor: (and no $current): passkey\_removed then says an administrator removed it, and PasskeyRemoved carries the actor. Same rules as for two-factor.

## How the modes combine at login

| Setting | Effect |
| --- | --- |
| `two_factor.mode = required` | Accounts without TOTP must enrol during login (or satisfy it with a passkey when passkey\_satisfies\_required). |
| `passkeys.mode = required` | Accounts without a passkey must enrol one during login — any login method, also an email login that two\_factor.after\_email\_login = false exempts from the second factor. |
| `passkeys.second_factor` | Whether a passkey may (allowed) or must (required\_when\_enrolled, required) be the second step after a password or email login. |
| `passkeys.satisfies_mfa` | A user-verified passkey login counts as multi-factor on its own — also under a risk step-up; with false a step-up demands TOTP. |

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/auth-for-laravel.md)
