Signal API¶
Info
Library since v5.3.0. Symfony bundle helpers added in v5.4.0.
The WebAuthn Signal API (W3C §5.1.10) lets relying parties notify the browser's passkey manager about credential state changes. The flow is purely outbound from the page: the relying party composes the payload server-side, the page calls navigator.credentials.signalXxx(), and the user agent forwards the action to attached authenticators. Nothing comes back: the call returns Promise<undefined>, fire-and-forget.
What is the Signal API?¶
When users manage their passkeys on the server side (removing credentials, updating profile information), the client platform may still display outdated information. The Signal API provides a standardized way to inform the client about:
- Which credentials are still valid for a user
- Updated user details (name, display name)
- Credentials that are no longer recognized by the server
Danger
Privacy gates per W3C §14.6.3. Two of the three signals leak PII to whoever receives them, so they must only be exposed to authenticated callers:
| Signal | Authentication required? |
|---|---|
signalUnknownCredential |
No. The credential id is one the caller already presented (e.g. failed login attempt). No leak. |
signalAllAcceptedCredentials |
Yes. The full credential id list correlates a user across relying parties. |
signalCurrentUserDetails |
Yes. User handle plus name/displayName are PII. |
Warning
Caveat on AllAcceptedCredentials (W3C §5.1.10.3). Credentials missing from allAcceptedCredentialIds may be removed or hidden, potentially irreversibly by the authenticator. Always derive the list exhaustively from your truth source, never from a partial view.
Signal types¶
AllAcceptedCredentials¶
Informs the client about all credentials that the server currently recognizes for a given user. This allows the client to remove any credentials that are no longer valid.
<?php
declare(strict_types=1);
use Webauthn\PublicKeyCredentialDescriptor;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialUserEntity;
use Webauthn\Signal\AllAcceptedCredentials;
$rpEntity = PublicKeyCredentialRpEntity::create(id: 'example.com');
$userEntity = PublicKeyCredentialUserEntity::create(
'john.doe',
$userHandle,
'John Doe'
);
// List of credential descriptors still valid for this user
$acceptedCredentials = [
PublicKeyCredentialDescriptor::create('public-key', $credentialId1),
PublicKeyCredentialDescriptor::create('public-key', $credentialId2),
];
$signal = new AllAcceptedCredentials($rpEntity, $userEntity, $acceptedCredentials);
CurrentUserDetails¶
Informs the client about updated user details. Use this when a user changes their username or display name to keep the client's passkey list accurate.
<?php
declare(strict_types=1);
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialUserEntity;
use Webauthn\Signal\CurrentUserDetails;
$rpEntity = PublicKeyCredentialRpEntity::create(id: 'example.com');
$userEntity = PublicKeyCredentialUserEntity::create(
'new.username', // Updated username
$userHandle,
'New Display Name' // Updated display name
);
$signal = new CurrentUserDetails($rpEntity, $userEntity);
UnknownCredential¶
Informs the client that a specific credential is not recognized by the server. This can occur when a credential has been deleted server-side or was never registered.
<?php
declare(strict_types=1);
use Webauthn\PublicKeyCredentialDescriptor;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\Signal\UnknownCredential;
$rpEntity = PublicKeyCredentialRpEntity::create(id: 'example.com');
$unknownCredential = PublicKeyCredentialDescriptor::create('public-key', $credentialId);
$signal = new UnknownCredential($rpEntity, $unknownCredential);
Serialization¶
Signals can be serialized to JSON using the Symfony Serializer. The framework provides dedicated denormalizers for each signal type.
<?php
declare(strict_types=1);
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
// Serialize the signal to JSON
$json = $serializer->serialize(
$signal,
'json',
[AbstractObjectNormalizer::SKIP_NULL_VALUES => true]
);
// The JSON output follows the W3C Signal API format
// For AllAcceptedCredentials:
// {
// "rpId": "example.com",
// "userId": "...",
// "allAcceptedCredentialIds": ["...", "..."]
// }
Use cases¶
After credential deletion¶
When a user removes a passkey from your application, send an AllAcceptedCredentials signal with the remaining credentials so the client can update its list.
After profile update¶
When a user changes their username or display name, send a CurrentUserDetails signal so the client displays the correct information in its passkey picker.
During authentication¶
If an authentication attempt references a credential that doesn't exist in your database, send an UnknownCredential signal to help the client clean up orphaned passkeys.
Info
The W3C spec recommends running signalAllAcceptedCredentials and signalCurrentUserDetails on every sign-in: authenticators may have been detached when the underlying event (delete, profile update) fired, so the next login is the natural moment to push the up-to-date state.
Symfony bundle helpers¶
Info
New in v5.4.0
The Symfony bundle ships two helpers that build the right payload from your repository and wrap them in a JSON envelope the client picks up. Wire them from your own SuccessHandler and FailureHandler (no bundle configuration), and you keep full control over when signals are emitted.
Building signals with WebauthnSignalFactory¶
Webauthn\Bundle\Service\WebauthnSignalFactory is autowired and exposes three builders:
| Method | Purpose |
|---|---|
forUnknownCredential(string $rpId, PublicKeyCredentialDescriptor $credential) |
Build an UnknownCredential signal from a credential descriptor (e.g. the one a user just failed to authenticate with). |
forAllAccepted(string $rpId, PublicKeyCredentialUserEntity $user) |
Build an AllAcceptedCredentials signal. The descriptor list is derived exhaustively from CredentialRecordRepositoryInterface::findAllForUserEntity() to defuse the "potentially irreversible" deletion warning of W3C §5.1.10.3. |
forCurrentUser(string $rpId, PublicKeyCredentialUserEntity $user) |
Build a CurrentUserDetails signal carrying the user's current name and displayName. |
Wrapping the response with WebauthnSignalResponse::withSignals()¶
Webauthn\Bundle\Service\WebauthnSignalResponse produces a JsonResponse that combines your application payload with a top-level signals: [{type, options}] envelope:
{
"your": "payload",
"signals": [
{
"type": "allAcceptedCredentials",
"options": {
"rpId": "example.com",
"userId": "dXNlci1oYW5kbGUtYnl0ZXM",
"allAcceptedCredentialIds": ["Y3JlZC1B", "Y3JlZC1C"]
}
},
{
"type": "currentUserDetails",
"options": {
"rpId": "example.com",
"userId": "dXNlci1oYW5kbGUtYnl0ZXM",
"name": "alice@example.com",
"displayName": "Alice Liddell"
}
}
]
}
The Stimulus base controller picks the envelope up automatically (see Stimulus Integration below).
Emit on every sign-in: custom SuccessHandler¶
The W3C spec recommends emitting allAcceptedCredentials and currentUserDetails on every sign-in so authenticators that were detached when the change happened catch up later. A focused SuccessHandler does exactly that:
<?php
declare(strict_types=1);
namespace App\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Webauthn\Bundle\Security\Handler\SuccessHandler;
use Webauthn\Bundle\Service\WebauthnSignalFactory;
use Webauthn\Bundle\Service\WebauthnSignalResponse;
use Webauthn\PublicKeyCredential;
use Webauthn\PublicKeyCredentialOptions;
use Webauthn\PublicKeyCredentialUserEntity;
final readonly class WebauthnSignalingSuccessHandler implements SuccessHandler
{
private const RP_ID = 'example.com';
public function __construct(
private WebauthnSignalFactory $signals,
private WebauthnSignalResponse $response,
) {
}
public function onSuccess(
Request $request,
?PublicKeyCredential $publicKeyCredential = null,
?PublicKeyCredentialOptions $publicKeyCredentialOptions = null,
?PublicKeyCredentialUserEntity $userEntity = null,
): Response {
if ($userEntity === null) {
return $this->response->withSignals(['status' => 'ok'], []);
}
return $this->response->withSignals(
['status' => 'ok'],
[
$this->signals->forAllAccepted(self::RP_ID, $userEntity),
$this->signals->forCurrentUser(self::RP_ID, $userEntity),
],
);
}
}
Drop stale credentials: custom FailureHandler¶
When an assertion fails because the relying party no longer recognises the credential id presented, emit signalUnknownCredential so the user agent can drop the stale passkey from its picker.
The bundle's response controllers pass three optional arguments to FailureHandler::onFailure (the deserialized PublicKeyCredential, the stored options, and the resolved user entity) so the handler can build a signal without re-parsing the request. Add them to your signature:
<?php
declare(strict_types=1);
namespace App\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
use Webauthn\Bundle\Security\Handler\FailureHandler;
use Webauthn\Bundle\Service\WebauthnSignalFactory;
use Webauthn\Bundle\Service\WebauthnSignalResponse;
use Webauthn\Exception\AuthenticatorResponseVerificationException;
use Webauthn\PublicKeyCredential;
use Webauthn\PublicKeyCredentialOptions;
use Webauthn\PublicKeyCredentialUserEntity;
final readonly class WebauthnSignalingFailureHandler implements FailureHandler
{
private const RP_ID = 'example.com';
public function __construct(
private WebauthnSignalFactory $signals,
private WebauthnSignalResponse $response,
) {
}
public function onFailure(
Request $request,
?Throwable $exception = null,
?PublicKeyCredential $publicKeyCredential = null,
?PublicKeyCredentialOptions $publicKeyCredentialOptions = null,
?PublicKeyCredentialUserEntity $userEntity = null,
): Response {
$signals = [];
if (
$publicKeyCredential !== null
&& $exception instanceof AuthenticatorResponseVerificationException
) {
$signals[] = $this->signals->forUnknownCredential(
self::RP_ID,
$publicKeyCredential->getPublicKeyCredentialDescriptor(),
);
}
return $this->response->withSignals(
['status' => 'error', 'errorMessage' => $exception?->getMessage() ?? 'Unexpected error'],
$signals,
);
}
}
Info
Why the extra arguments? In v5.x the FailureHandler::onFailure interface only declares Request and ?Throwable. The three additional optional arguments (?PublicKeyCredential, ?PublicKeyCredentialOptions, ?PublicKeyCredentialUserEntity) are passed by the bundle's response controllers but live as a PHPDoc-only signature on the interface for backwards compatibility. Implementations that have not updated their signature ignore the extras silently. They will become required parameters in 6.0.
Wiring¶
Both helpers are autowired. Reference your handlers from the firewall configuration:
security:
firewalls:
main:
webauthn:
authentication:
success_handler: App\Security\WebauthnSignalingSuccessHandler
failure_handler: App\Security\WebauthnSignalingFailureHandler
Authenticator + passport flow¶
Info
Recommended flow. The legacy webauthn firewall is being phased out in favour of a standard Symfony custom authenticator with the WebauthnPassport / WebauthnBadge pair. The signal helpers work transparently in this world too: no SuccessHandler / FailureHandler to wire separately, everything lives on the authenticator.
Custom authenticator¶
Extend Webauthn\Bundle\Security\Authentication\WebauthnAuthenticator and inject the two signal helpers. onAuthenticationSuccess() receives a WebauthnToken (built by the bundle) that already exposes getPublicKeyCredentialUserEntity(), getPublicKeyCredentialOptions() and getPublicKeyCredentialDescriptor(). onAuthenticationFailure() receives a WebauthnAuthenticationFailureException that carries the deserialized credential when the failure happened after the bundle recognised the request as a WebAuthn ceremony.
<?php
declare(strict_types=1);
namespace App\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Webauthn\Bundle\Security\Authentication\Exception\WebauthnAuthenticationFailureException;
use Webauthn\Bundle\Security\Authentication\Token\WebauthnToken;
use Webauthn\Bundle\Security\Authentication\WebauthnAuthenticator as BaseWebauthnAuthenticator;
use Webauthn\Bundle\Security\Authentication\WebauthnBadge;
use Webauthn\Bundle\Security\Authentication\WebauthnPassport;
use Webauthn\Bundle\Service\WebauthnSignalFactory;
use Webauthn\Bundle\Service\WebauthnSignalResponse;
final class WebauthnAuthenticator extends BaseWebauthnAuthenticator
{
private const RP_ID = 'example.com';
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly WebauthnSignalFactory $signals,
private readonly WebauthnSignalResponse $response,
) {
}
public function authenticate(Request $request): Passport
{
return new WebauthnPassport(
new WebauthnBadge($request->getHost(), $request->request->get('_assertion', '')),
[],
);
}
public function onAuthenticationSuccess(
Request $request,
TokenInterface $token,
string $firewallName,
): ?Response {
\assert($token instanceof WebauthnToken);
$user = $token->getPublicKeyCredentialUserEntity();
return $this->response->withSignals(
['success' => true],
[
$this->signals->forAllAccepted(self::RP_ID, $user),
$this->signals->forCurrentUser(self::RP_ID, $user),
],
);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$signals = [];
if ($exception instanceof WebauthnAuthenticationFailureException) {
$signal = $this->signals->forUnknownCredentialFromException(self::RP_ID, $exception);
if ($signal !== null) {
$signals[] = $signal;
}
}
return $this->response->withSignals(
['success' => false, 'errorMessage' => $exception->getMessage()],
$signals,
);
}
protected function getLoginUrl(Request $request): string
{
return $this->urlGenerator->generate('app_login');
}
}
Wiring¶
The authenticator is registered as any other Symfony custom authenticator:
security:
firewalls:
main:
custom_authenticator: 'App\Security\WebauthnAuthenticator'
No webauthn: block under the firewall, no success_handler / failure_handler to declare separately.
About WebauthnAuthenticationFailureException¶
Failures raised inside WebauthnBadgeListener after it has recognised the request as a WebAuthn ceremony are wrapped in Webauthn\Bundle\Security\Authentication\Exception\WebauthnAuthenticationFailureException. The exception extends Symfony\Component\Security\Core\Exception\AuthenticationException, so any existing catch on that parent keeps matching, and exposes:
| Property | Description |
|---|---|
publicKeyCredential |
The deserialized PublicKeyCredential (carries rawId / descriptor). null if the failure happened pre-deserialization. |
authenticatorResponse |
The matching AuthenticatorResponse (assertion or attestation). |
publicKeyCredentialOptions |
The stored PublicKeyCredentialOptions of the ceremony. |
userEntity |
The resolved PublicKeyCredentialUserEntity if the badge got that far. |
WebauthnSignalFactory::forUnknownCredentialFromException() extracts the descriptor from the carried credential and produces the signal in one call. Pre-deserialization failures (malformed body, missing stored options) keep the historical silent-fail so other authenticators on the same firewall stay free to handle the request.
Stimulus integration¶
The @web-auth/webauthn-stimulus controllers (AuthenticationController, RegistrationController, WebauthnController) pick up the signals: [...] envelope automatically after a successful verify call and dispatch each entry to the matching PublicKeyCredential.signalXxx() JS API. Each call is feature-detected, silently no-ops on browsers that do not expose the method, and swallows the spec-defined TypeError / SecurityError so the rest of your application flow proceeds unchanged.
If you want to fire signals outside of a controller flow (e.g. from a custom JS handler after a profile update), the package also exports four helpers from its entry point:
import {
dispatchUnknownCredential,
dispatchAllAcceptedCredentials,
dispatchCurrentUserDetails,
dispatchSignals,
} from '@web-auth/webauthn-stimulus';
// One-shot single signal:
await dispatchUnknownCredential({ rpId: 'example.com', credentialId: 'aabbcc' });
// Or replay a server-issued envelope:
await dispatchSignals({
signals: [
{ type: 'allAcceptedCredentials', options: { rpId, userId, allAcceptedCredentialIds } },
{ type: 'currentUserDetails', options: { rpId, userId, name, displayName } },
],
});
Info
Browser support. The Signal API requires Chrome/Edge ≥ 128, Safari ≥ 18.4 (or 18.5 for the allAcceptedCredentials and currentUserDetails variants). Firefox does not implement it yet. The Stimulus dispatch is intentionally fire-and-forget so the rest of your application flow proceeds unchanged on unsupported user agents.
See also¶
- WebAuthn L3 §5.1.10, Signal Methods: the W3C specification.
- WebAuthn L3 §14.6.3, Privacy leak via credential IDs: the rationale behind the privacy gates above.
- Credential Record: credential storage.