A way to move beyond passwords
Web Authentication, frequently referenced as WebAuthn, is a set of technologies and APIs to provide user authentication using modern cryptography.
Instead of passwords and hashing, WebAuthn allows users to generate encryption keypairs, provide the public key to the server, and authenticate by signing server-generated challenges using the private key that never leaves their possession.
This means that servers never touch sensitive data and cannot leak authentication information should a breach ever occur. This also means that users do not have to manage passwords for individual websites, and can instead rely on tools provided by operating systems, browsers, and hardware security keys.
This will cover the basic workflows for integrating this library to your web application.
Classes referenced in the examples may omit the Firehed\WebAuthn namespace prefix for brevity.
There's a complete set of working examples in the examples directory.
Application logic is kept to a bare minimum in order to highlight the most important workflow steps.
First, install the library:
composer require firehed/webauthn
Create a RelyingParty instance.
This MUST match the complete origin that users will interact with; e.g. https://login.example.com:1337.
The protocol is always required; the port must only be present if using a non-standard port and must be excluded for standard ports.
$rp = new RelyingParty('https://www.example.com');Important: WebAuthn will only work in a "secure context".
This means that the domain MUST run over https, with a sole exception for localhost.
See https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts for more info.
This step takes place either when a user is first registering, or later on to supplement or replace their password.
- Create an endpoint that will return a new, random Challenge. This may be stored in a user's session or equivalent; it needs to be kept statefully server-side. Send it to the user as base64.
Note
Challenges can be serialized, and thus can be stored directly in a session, as well as most caches. If you are not using sessions in your application (e.g. an API), make sure it's stored in a way that's associated with the authenticating user.
<?php
use Firehed\WebAuthn\ExpiringChallenge;
// Generate challenge
$challenge = ExpiringChallenge::withLifetime(120);
// Store server-side; adjust to your app's needs
session_start();
$_SESSION['webauthn_challenge'] = $challenge;
// Send to user
header('Content-type: application/json');
echo json_encode($challenge->getBase64());- In client Javascript code, read the challege and provide it to the WebAuthn APIs. You will also need the registering user's identifier and some sort of username
// See https://www.w3.org/TR/webauthn-2/#sctn-sample-registration for a more annotated example
if (!window.PublicKeyCredential) {
// Browser does not support WebAuthn. Exit and fall back to another flow.
return
}
// This comes from your app/database, fetch call, etc. Depending on your app's
// workflow, the user may or may not have a password (which isn't relevant to WebAuthn).
const userInfo = {
name: 'Username', // chosen name or email, doesn't really matter
id: 'abc123', // any unique id is fine; uuid or PK is preferable
}
const response = await fetch('/readmeRegisterStep1.php')
const challengeB64 = await response.json()
const challenge = atob(challengeB64) // base64-decode
const createOptions = {
publicKey: {
rp: {
name: 'My website',
},
user: {
name: userInfo.name,
displayName: 'User Name',
id: Uint8Array.from(userInfo.id, c => c.charCodeAt(0)),
},
challenge: Uint8Array.from(challenge, c => c.charCodeAt(0)),
pubKeyCredParams: [
{
alg: -7, // ES256
type: "public-key",
},
],
},
attestation: 'direct',
}
// Call the WebAuthn browser API and get the response. This may throw, which you
// should handle. Example: user cancels or never interacts with the device.
const credential = await navigator.credentials.create(createOptions)
// Format the credential to send to the server. This must match the format
// handed by the ResponseParser class. The formatting code below can be used
// without modification.
const dataForResponseParser = {
rawId: Array.from(new Uint8Array(credential.rawId)),
type: credential.type,
attestationObject: Array.from(new Uint8Array(credential.response.attestationObject)),
clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
}
// Send this to your endpoint - adjust to your needs.
const request = new Request('/readmeRegisterStep3.php', {
body: JSON.stringify(dataForResponseParser),
headers: {
'Content-type': 'application/json',
},
method: 'POST',
})
const result = await fetch(request)
// handle result, update user with status if desired.- Parse and verify the response and, if successful, associate with the user.
<?php
use Firehed\WebAuthn\{
Codecs,
ResponseParser,
};
$json = file_get_contents('php://input');
$data = json_decode($json, true);
$parser = new ResponseParser();
$createResponse = $parser->parseCreateResponse($data);
$rp = $valueFromSetup; // e.g. $psr11Container->get(RelyingParty::class);
$challenge = $_SESSION['webauthn_challenge'];
try {
$credential = $createResponse->verify($challenge, $rp);
} catch (Throwable) {
// Verification failed. Send an error to the user?
header('HTTP/1.1 403 Unauthorized');
return;
}
// Store the credential associated with the authenticated user. This is
// incredibly application-specific. Below is a sample table.
/*
CREATE TABLE user_credentials (
id text PRIMARY KEY,
user_id text,
credential text,
FOREIGN KEY (user_id) REFERENCES users(id)
);
*/
$codec = new Codecs\Credential();
$encodedCredential = $codec->encode($credential);
$pdo = getDatabaseConnection();
$stmt = $pdo->prepare('INSERT INTO user_credentials (id, user_id, credential) VALUES (:id, :user_id, :encoded);');
$result = $stmt->execute([
'id' => $credential->getStorageId(),
'user_id' => $user->getId(), // $user comes from your authn process
'encoded' => $encodedCredential,
]);
// Continue with normal application flow, error handling, etc.
header('HTTP/1.1 200 OK');Important
The getStorageId() value from the credential will be used during authentication.
Be sure to store it alongside the encoded version.
It should be globally unique, and treated as such in your durable storage.
- There is no step 4. The verified credential is now stored and associated with the user!
Note: this workflow may be a little different if supporting passkeys. Updated samples will follow.
Before starting, you will need to collect the username or id of the user trying to authenticate, and retreive the user info from storage. This assumes the same schema from the previous Registration example.
- Create an endpoint that will return a Challenge and any credentials associated with the authenticating user:
<?php
use Firehed\WebAuthn\{
Codecs,
ExpiringChallenge,
};
session_start();
$pdo = getDatabaseConnection();
$user = getUserByName($pdo, $_POST['username']);
if ($user === null) {
header('HTTP/1.1 404 Not Found');
return;
}
$_SESSION['authenticating_user_id'] = $user['id'];
// See examples/functions.php for how this works
$credentialContainer = getCredentialsForUserId($pdo, $user['id']);
$challenge = ExpiringChallenge::withLifetime(120);
$_SESSION['webauthn_challenge'] = $challenge;
// Send to user
header('Content-type: application/json');
echo json_encode([
'challengeB64' => $challenge->getBase64(),
'credential_ids' => $credentialContainer->getBase64Ids(),
]);Warning
Returning credential_ids for use in allowCredentials (below) can leak information about who has registered and what sort of authentication devices they possess.
See §14.6.3 in the spec for more information.
You MAY elide that value from the response and leave allowCredentials unconfigured in the JS code.
- In client Javascript code, read the data from above and provide it to the WebAuthn APIs.
// Get this from a form, etc.
const username = document.getElementById('username').value
// This can be any format you want, as long as it works with the above code
const response = await fetch('/readmeLoginStep1.php', {
method: 'POST',
body: 'username=' + username,
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
})
const data = await response.json()
// Format for WebAuthn API
const getOptions = {
publicKey: {
challenge: Uint8Array.from(atob(data.challengeB64), c => c.charCodeAt(0)),
allowCredentials: data.credential_ids.map(id => ({
id: Uint8Array.from(atob(id), c => c.charCodeAt(0)),
type: 'public-key',
}))
},
}
// Similar to registration step 2
// Call the WebAuthn browser API and get the response. This may throw, which you
// should handle. Example: user cancels or never interacts with the device.
const credential = await navigator.credentials.get(getOptions)
// Format the credential to send to the server. This must match the format
// handed by the ResponseParser class. The formatting code below can be used
// without modification.
const dataForResponseParser = {
rawId: Array.from(new Uint8Array(credential.rawId)),
type: credential.type,
authenticatorData: Array.from(new Uint8Array(credential.response.authenticatorData)),
clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
signature: Array.from(new Uint8Array(credential.response.signature)),
userHandle: Array.from(new Uint8Array(credential.response.userHandle)),
}
// Send this to your endpoint - adjust to your needs.
const request = new Request('/readmeLoginStep3.php', {
body: JSON.stringify(dataForResponseParser),
headers: {
'Content-type': 'application/json',
},
method: 'POST',
})
const result = await fetch(request)
// handle result - if it went ok, perform any client needs to finish auth process- Parse and verify the response. If successful, update the credential & finish app login process.
<?php
use Firehed\WebAuthn\{
Codecs,
ResponseParser,
};
session_start();
$json = file_get_contents('php://input');
$data = json_decode($json, true);
$parser = new ResponseParser();
$getResponse = $parser->parseGetResponse($data);
$rp = $valueFromSetup; // e.g. $psr11Container->get(RelyingParty::class);
$challenge = $_SESSION['webauthn_challenge'];
$credentialContainer = getCredentialsForUserId($pdo, $_SESSION['authenticating_user_id']);
try {
$updatedCredential = $getResponse->verify($challenge, $rp, $credentialContainer);
} catch (Throwable) {
// Verification failed. Send an error to the user?
header('HTTP/1.1 403 Unauthorized');
return;
}
// Update the credential
$codec = new Codecs\Credential();
$encodedCredential = $codec->encode($updatedCredential);
$stmt = $pdo->prepare('UPDATE user_credentials SET credential = :encoded WHERE id = :id AND user_id = :user_id');
$result = $stmt->execute([
'id' => $updatedCredential->getStorageId(),
'user_id' => $_SESSION['authenticating_user_id'],
'encoded' => $encodedCredential,
]);
header('HTTP/1.1 200 OK');
// Send back whatever your webapp needs to finish authenticationWarning
Failing to update the stored credential can expose your application to replay attacks. Always update the stored credential with the returned value after authentication.
Cleanup Tasks
-
replace step 1 with just generating challenge (still put in session)
-
step 2 removes allowCredentials, adds mediation:conditional
-
step 3 replaces user from session with a user lookup from GetResponse.userHandle
-
Pull across PublicKeyInterface
-
Pull across ECPublicKey
-
Move key formatting into COSE key/turn COSE into key parser?
-
Clearly define public scoped interfaces and classes
- Public:
- ResponseParser (interface?)
- Challenge (DTO / serialization-safety in session)
- RelyingParty
- CredentialInterface
- Responses\AttestationInterface & Responses\AssertionInterface
- Errors
- Internal:
- Attestations
- AuthenticatorData
- BinaryString
- Credential
- Certificate
- CreateRespose & GetResponse
- Public:
-
Rework BinaryString to avoid binary in stack traces
-
Use BinaryString consistently
- COSEKey.decodedCbor
- Attestations\FidoU2F.data
-
Establish required+best practices for data storage
- CredentialInterface + codec?
- Relation to user
- Keep signCount up to date (7.2.21)
- 7.1.22 ~ credential in use
-
Scan through repo for FIXMEs & missing verify steps
- Counter handling in (7.2.21)
- isUserVerificationRequired - configurability (7.1.15, 7.2.17)
- Trust anchoring (7.1.20; result of AO.verify)
- How to let client apps assess trust ambiguity (7.1.21)
- Match algorithm in create() to createOptions (7.1.16)
-
BC plan for verification trust paths
-
Attestation statment return type/info
-
BinaryString easier comparison?
-
Lint issues
-
Import sorting
Security/Risk:
- Certificate chain (7.1.20-21)
- RP policy for cert attestation type / attestation trustworthiness (7.1.21)
- Sign count LTE stored value (7.2.21)
Blocked?
- ClientExtensionResults (7.1.4, 7.1.17, 7.2.4, 7.2.18) All of the handling seems to be optional. I could not get it to ever come out non-empty.
- TokenBinding (7.1.10, 7.2.14) Unsupported except in Edge?
Naming?
- Codecs\Credential
- Codecs - static vs instance?
- Credential::getStorageId()
- ResponseParser -> Codecs?
- CreateResponse/GetResponse -> Add interfaces?
- Parser -> parseXResponse => parse{Attestation|Assertion}Data
- Error* -> Errors*
Nice to haves/Future scope:
- Refactor FIDO attestation to not need AD.getAttestedCredentialData
- grab credential from AD
- check PK type
- ExpiringChallenge & ChallengeInterface
- JSON generators:
- PublicKeyCredentialCreationOptions
- PublicKeyCredentialRequestOptions
- note: no way to do straight json to arraybuffer?
- emit as jsonp?
- Permit changing the Relying Party ID
- Refactor COSEKey to support other key types, use enums & ADT-style composition
- GetResponse userHandle
- Assertion.verify (CredentialI | CredentialContainer)
Testing:
- Happy path w/ FidoU2F
- Happy path with macOS/Safari WebAuthn
- Challenge mismatch (create+get)
- Origin mismatch (CDJ)
- RPID mismatch (AuthenticatorData)
- [s] !userPresent
- !userVerified & required
- [s] !userVerified & not required
- PK mismatched in verify??
- App-persisted data SerDe
- Parser handling of bad input formats
Use the exact data format shown in the examples above (dataForResponseParser) and use the ResponseParser class to process them.
Those wire formats are covered by semantic versioning and guaranteed to not have breaking changes outside of a major version.
Similarly, for data storage, the output of Codecs\Credential::encode() are also covered.
Challenges generated by your server SHOULD expire after a short amount of time.
You MAY use the ExpiringChallenge class for convenience (e.g. $challenge = ExpiringChallenge::withLifetime(60);), which will throw an exception if the specified expiration window has been exceeded.
It is RECOMMENDED that your javascript code uses the timeout setting (denoted in milliseconds) and matches the server-side challenge expiration, give or take a few seconds.
Note: the W3C specification recommends a timeout in the range of 15-120 seconds.
The library is built around a "fail loudly" principle.
During both the registration and authentication process, if an exception is not thrown it means that the process succeeded.
Be prepared to catch and handle these exceptions.
All exceptions thrown by the library implement Firehed\WebAuthn\Errors\WebAuthnErrorInterface, so if you want to only catch library errors (or test for them in a generic error handler), use that interface.
- Credentials SHOULD have a 1-to-many relationship with users; i.e. a user should be able to have more than one associated Credential
- The credential id SHOULD be unique. If during registration this unique constraint is violated AND it's associated with a different user, your application MUST handle this situation, either by returning an error or de-associating the credential with the other user. See https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential section 7.1 step 22 for more info.
- The WebAuthn spec makes no guarantees about the maximum credential id length, though none were observed to be over 64 bytes (raw binary) during library development. It is RECOMMENDED to permit storage of up to 255 bytes, as this tends to be the most compact variable-length encoding in many databases.
- The credential SHOULD be stored as a string encoded by
Codecs\Credential::encode(), and decoded with::decode. The storage system must allow for values up to 64KiB (65,535 bytes) of ASCII; the encoding will not contain values out of the base64 range. - It's RECOMMENDED to allow users to provide a name associated with their credentials (e.g. "work laptop", "backup fido key").
- You MAY use the storage identifier (
getStorageId()) as a primary key if storing in a relational database. It MAY also be used as a surrogate key.
Simplest version - uses own ID as primary key:
CREATE TABLE `credentials` (
`id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
`user_id` bigint unsigned NOT NULL,
`credential` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;More common setup:
- Separates primary key from credential ID. This tends to increase flexibility with ORMs, etc.
- Adds a
nicknamefield, where users can provide a label for their own use. - Stores the credential data in ASCII format (the outputs are guaranteed to be ASCII safe). This can reduce storage size slightly.
- Note: Store and retrieve both library-provided values without any modification. Avoid storage mechanisms that trim data, change case, etc.
CREATE TABLE `credentials` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`credential_id` varchar(255) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL COMMENT 'credential->getStorageId()',
`user_id` bigint unsigned NOT NULL,
`credential` varchar(255) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL COMMENT 'encode() result',
`nickname` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `credential_id` (`credential_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;- The
verify()method called during authentication returns an updated credential. Your application SHOULD update the persisted value each time this happens. Doing so increases security, as it improves the ability to detect and act on replay attacks.
This library follows Semantic Versioning.
Note that classes or methods marked as @internal are NOT covered by the same guarantees.
Anything intended explicitly for public use has been marked with @api.
If there are any unclear areas, please file an issue.
There are additional notes in Best Practices / Data Handling around this.
This library is a rework of u2f-php, which is built around a much earlier version of the spec known as U2F, pioneered by YubiCo with their YubiKey products.
WebAuthn continues to support YubiKeys (and other U2F devices), as does this library.
Instead of building a v2 of that library, a clean break was found to be easier:
- There's no need to deal with moving the Composer package (the u2f name no longer makes sense)
- A lot of the data storage mechanisms needed to be significantly reworked
- The platform extensibility in WebAuthn did not translate well to the previous structures
- Dropping support for older PHP versions & using new features simplified a lot
WebAuthn spec:
- https://www.w3.org/TR/webauthn-2/
- https://www.w3.org/TR/2021/REC-webauthn-2-20210408/ (spec implemented to this version)
General quickstart guide:
Intro to passkeys: