phpMyAdmin 5.2 WebAuthn Key Registration Fails with TypeError Due to Raw AAGUID Bytes
Contents
TL;DR
What happened In phpMyAdmin 5.2.1 through 5.2.3 running the fallback CustomServer implementation, attempting to register a WebAuthn hardware security key fails with Internal error: TypeError, and settings are not saved.
Why it fails CustomServer inserts the 16-byte Authenticator Attestation GUID (AAGUID) directly into JSON as a raw byte string instead of encoding it into a text format (such as a UUID string or Base64 URL-safe). Because arbitrary 16-byte values rarely form valid UTF-8, json_encode() returns false, which causes a TypeError when passed to a strictly typed string parameter.
What we tested In a loopback-only (127.0.0.1) Docker environment, we ran the full registration handshake. While an all-zero (0x00) AAGUID succeeded, all test cases containing bytes 0x80 or higher triggered TypeError. When migrating valid records to an official release running webauthn-lib, login failed before reaching the 2FA prompt with Safe\Exceptions\UrlException. Converting the aaguid field to a standard UUID string restored normal 2FA operation.
Fix status Fixed on September 29, 2026, in the QA_5_2 and master development branches by converting AAGUID bytes into lowercase UUID strings. No official release has shipped the fix yet (latest is 5.2.3 from October 8, 2025).
Who is affected Only setups using the custom fallback implementation. Official tarballs, zip archives, and Docker images bundle web-auth/webauthn-lib and do not use CustomServer.
When attempting to register a hardware security key in phpMyAdmin running the fallback CustomServer, saving the configuration fails with Internal error: TypeError. Following our investigation into the phpMyAdmin 5.2.3 WebAuthn 2FA bypass, we tested the registration handshake and identified why credential persistence breaks.
Registration fails with TypeError
phpMyAdmin supports two-factor authentication via “Hardware Security Key (WebAuthn/FIDO2)”.
When enabled from the user preferences screen, the browser issues a credential registration request to the security key, and phpMyAdmin validates the authenticator’s attestation response before persisting it to configuration storage.
In environments where web-auth/webauthn-lib is not installed, validation is handled by phpMyAdmin’s internal CustomServer class.
This fallback implementation allows WebAuthn features to run without third-party library dependencies.
When registering a hardware security key in an environment running CustomServer, saving fails during the final persistence step.
The UI displays only this generic error:
Internal error: TypeError
Because phpMyAdmin’s error handler suppresses stack traces, the root cause cannot be diagnosed from the web interface.
The two-factor authentication configuration is never saved, so 2FA remains disabled on subsequent logins.
Unencoded AAGUID in CustomServer
An AAGUID is a 16-byte identifier indicating the model of the authenticator, defined in the WebAuthn specification as a UUID.
It arrives as raw binary bytes within the authenticatorData structure of the attestation response.
CustomServer passes these 16 bytes through two functions without converting them to text.
First, getAuthenticatorData() extracts the raw bytes from the attestation data stream:
/** Authenticator Attestation GUID */
$aaguid = $authDataStream->take(16);
These bytes are stored in $attestedCredentialData['aaguid'] without transformation.
Next, parseAndValidateAttestationResponse() constructs the credential record returned to the caller.
While three other binary fields are explicitly converted to Base64 URL-safe strings, aaguid is left as raw bytes:
$encodedCredentialId = sodium_bin2base64(
$authenticatorData['attestedCredentialData']['credentialId'],
SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING
);
$encodedCredentialPublicKey = sodium_bin2base64(
$authenticatorData['attestedCredentialData']['credentialPublicKey'],
SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING
);
$userHandle = sodium_bin2base64(
sodium_base642bin($creationOptions['user']['id'], SODIUM_BASE64_VARIANT_ORIGINAL),
SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING
);
return [
'publicKeyCredentialId' => $encodedCredentialId,
'type' => 'public-key',
'transports' => [],
'attestationType' => $attestationObject['fmt'],
'aaguid' => $authenticatorData['attestedCredentialData']['aaguid'],
'credentialPublicKey' => $encodedCredentialPublicKey,
'userHandle' => $userHandle,
'counter' => $authenticatorData['signCount'],
];
Three fields receive explicit Base64 URL-safe strings, but aaguid directly retains the raw binary bytes from getAuthenticatorData().
The required conversion to a standard 36-character UUID string is missing entirely.
This return value is serialized into JSON and stored in the config_data column of the pma__userconfig table.
Verifying the registration flow in Docker
To verify what values are actually written at runtime, we ran the complete registration flow end-to-end in Docker.
Test environments
We set up two independent environments via Docker Compose, both restricted to 127.0.0.1.
The setup running CustomServer is referred to as the “CustomServer setup”, and the setup with web-auth/webauthn-lib is the “Library setup”.
| Attribute | CustomServer setup | Library setup |
|---|---|---|
| phpMyAdmin | 5.2.3 | 5.2.3 |
| Source | Git tag RELEASE_5_2_3 | Official website tarball |
web-auth/webauthn-lib | None (removed vendor/web-auth) | v3.3.12 |
| Active WebAuthn Server | CustomServer | WebauthnLibServer |
| PHP | 8.2.35 | 8.2.35 (with bcmath and gmp) |
| Scheme | HTTP | HTTPS (self-signed certificate) |
| Database | MySQL 8.0 | MySQL 8.0 |
bcmath and gmp extensions were added to the library setup because elliptic curve cryptographic verification fails immediately with Requires GMP or bcmath extension. if neither is loaded.
HTTPS is required because webauthn-lib enforces secure origins, throwing Invalid scheme. HTTPS required. under HTTP.
Inside each container, class_exists('Webauthn\Server') evaluated to false in the CustomServer setup and true in the Library setup.
Two-step registration handshake
Registration in phpMyAdmin requires two consecutive POST requests to index.php?route=/preferences/two-factor.
The first request sends 2fa_configure=WebAuthn.
phpMyAdmin invokes getCredentialCreationOptions() to generate a cryptographic challenge and session state, and embeds the parameters into the response HTML inside the data-creation-options attribute.
The second request submits the same parameter alongside webauthn_creation_response.
This payload contains the client response JSON generated by the authenticator, with clientDataJSON and attestationObject encoded in Base64.
CustomServer::parseAndValidateAttestationResponse() verifies the following criteria:
clientDataJSON.typemust equalwebauthn.create- The challenge must match the session challenge
- The
originhost must match the requested host - The first 32 bytes of
authDatamust match the SHA-256 hash of the host - The
User Present(UP) flag bit inauthDatamust be set attestationObject.fmtmust benonewith an emptyattStmt
We assembled an attestation response meeting these conditions using Python.
We generated a P-256 key pair, encoded the COSE public key in CBOR, and inserted it into authData.
Because we simulated the authenticator in software, we could supply arbitrary 16-byte AAGUID values.
Stored JSON structure and raw AAGUID
When setting the AAGUID to all zeros (0x00), registration succeeded.
The value stored in pma__userconfig.config_data appeared as follows (abbreviated):
{"2fa":{"backend":"WebAuthn","settings":{
"userHandle":"uWvKafjzCMr_qrGal8Zy3CsnjMaxGzDNxcDOvmJ-gc8",
"credentials":{"oaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2+v8A=":{
"publicKeyCredentialId":"oaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2-v8A",
"type":"public-key",
"transports":[],
"attestationType":"none",
"aaguid":"\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000",
"credentialPublicKey":"pQECAyYgASFYIMcanRPTFu2i8NQciTugui9e1qVy8GbeDRw71ae2M7b3IlggqED-nDQ7lsuV8swEl-uwQ-fSp-HMno8ENBosrEr2Mfs",
"userHandle":"uWvKafjzCMr_qrGal8Zy3CsnjMaxGzDNxcDOvmJ-gc8",
"counter":0}}}}}
publicKeyCredentialId, credentialPublicKey, and userHandle are stored as Base64 strings.
In contrast, aaguid contains 16 consecutive \u0000 escape sequences, which is a raw 16-byte binary string in JSON.
When parsed by PHP’s json_decode(), this field becomes a 16-byte string of null bytes (0x00), rather than a 36-character UUID string.
Registration results across four AAGUID patterns
We repeated the registration handshake with four distinct AAGUID patterns:
| AAGUID | Contains bytes >= 0x80 | Valid UTF-8 | Result | config_data written |
|---|---|---|---|---|
00000000000000000000000000000000 | No | Yes | Success | 605 bytes written |
2fc0579f814410bd0000000000000000 | Yes | No | Failed | No write occurred |
cb69481e8ff7403993ec0a2729a154a8 | Yes | No | Failed | No write occurred |
6028b017b1d44c02b4b3afcdafc96bb2 | Yes | No | Failed | No write occurred |
In all three failed cases, the interface returned Internal error: TypeError, and config_data remained unchanged.
Success or failure depends entirely on whether the AAGUID contains bytes with the high bit set (0x80 or higher).
Probability of random AAGUID bytes passing UTF-8 validation
Because an AAGUID is formatted as a UUID, its 16 bytes are essentially random values.
We evaluated the empirical probability of a random 16-byte sequence forming valid UTF-8 by generating 200,000 UUIDs in Python:
| Metric | Count | Percentage |
|---|---|---|
| Generated UUIDs | 200,000 | 100% |
Contains bytes >= 0x80 | 200,000 | 100% |
| Valid UTF-8 byte sequences | 16 | 0.0080% |
json_encode() failures | 199,984 | 99.9920% |
The mathematical probability of 16 random bytes remaining entirely within ASCII range (0x7F or lower) is 1 in 2^16 (roughly 0.0015%).
Any sequence containing bytes 0x80 or higher can only pass UTF-8 validation if those bytes coincidentally match multi-byte UTF-8 lead and continuation patterns.
Consequently, registration only succeeds when an authenticator returns an all-zero AAGUID or a specially formatted sequence.
While certain platform authenticators (built-in device authenticators) return all zeros, physical security keys almost universally fail.
How the TypeError occurs
graph TD
A["Authenticator attestationData"] --> B["CustomServer extracts raw 16 bytes via take(16)"]
B --> C{"Is byte sequence valid UTF-8?"}
C -->|Yes| D["json_encode succeeds and saves config"]
C -->|No| E["json_encode returns false"]
E --> F["bool passed to escapeString raises TypeError"]
D --> G["aaguid stored as raw bytes"]
G --> H["webauthn-lib cannot parse, throws UrlException"]
UserPreferences::save() converts configuration settings to JSON before constructing the SQL query:
$config_data = json_encode($config_array);
if ($has_config) {
$query = 'UPDATE ' . $query_table
. ' SET `timevalue` = NOW(), `config_data` = \''
. $dbi->escapeString($config_data)
. '\''
// ...
In PHP, json_encode() returns false when it encounters malformed UTF-8 characters; it does not throw an exception.
Its return type is string|false.
UserPreferences.php begins with declare(strict_types=1);, and DatabaseInterface::escapeString() declares its first argument strictly as string:
public function escapeString(string $str, $link = self::CONNECT_USER)
Under strict typing, passing false (bool) to a string parameter triggers an immediate TypeError.
Because save() does not check if json_encode() failed, the bool value flows directly into escapeString().
Reproducing this call in the PHP container produced the exact fatal error:
json_encode result type: bool
json_last_error_msg: Malformed UTF-8 characters, possibly incorrectly encoded
escapeString(false) => TypeError: PhpMyAdmin\DatabaseInterface::escapeString():
Argument #1 ($str) must be of type string, bool given
Credential migration to official library setups
Even when registration succeeds with an all-zero AAGUID, the stored record cannot be read by web-auth/webauthn-lib.
In webauthn-lib v3.3.12, PublicKeyCredentialSource::createFromArray() parses the aaguid field using this switch statement:
switch (true) {
case 36 === mb_strlen($data['aaguid'], '8bit'):
$uuid = Uuid::fromString($data['aaguid']);
break;
default: // Kept for compatibility with old format
$decoded = base64_decode($data['aaguid'], true);
$uuid = Uuid::fromBytes($decoded);
}
A 36-byte string is treated as a standard UUID string, whereas any other length is decoded as Base64 before being converted to 16 bytes.
A raw 16-byte binary string matches neither expectation.
Because the library imports use function Safe\base64_decode;, invalid Base64 input throws Safe\Exceptions\UrlException instead of returning false.
16 null bytes are not valid Base64, triggering an immediate exception.
We copied the raw binary record from the CustomServer database to the library setup’s pma__userconfig using SQL UNHEX() and attempted a valid login:
Record aaguid | Length | Password Auth Result | 2FA Screen Status |
|---|---|---|---|
Raw 16 bytes (saved by CustomServer) | 16 bytes | Internal error: Safe\Exceptions\UrlException: An error occured | Never displayed |
00000000-0000-0000-0000-000000000000 | 36 chars | 2FA prompt displayed | Success with valid signature |
Row 2 uses the exact same record with only the aaguid field replaced by a 36-character UUID string.
The raw byte version fails during response generation after password verification, so the 2FA prompt is never displayed to the user.
How phpMyAdmin accommodates missing trustPath fields
createFromArray() strictly validates all properties in $data, throwing The parameter "..." is missing if an expected key is absent.
CustomServer records omit the trustPath field. However, phpMyAdmin’s WebauthnLibServer explicitly fills this gap on read:
private function read(): array
{
$credentials = $this->twoFactor->config['settings']['credentials'];
foreach ($credentials as &$credential) {
if (isset($credential['trustPath'])) {
continue;
}
$credential['trustPath'] = ['type' => EmptyTrustPath::class];
}
return $credentials;
}
While phpMyAdmin bridges the schema difference for trustPath, it provides no fallback for aaguid, causing fatal crashes on raw bytes.
Conversely, credentials created in official library environments are serialized via jsonSerialize(), storing aaguid as a 36-character UUID string ($this->aaguid->toString()).
Because CustomServer does not inspect aaguid during assertion validation, migrating records from official packages to CustomServer environments does not trigger errors.
Fix status in upstream development branches
In the upstream CustomServer.php commit history, this logic was corrected in late September 2026:
$aaguid = $this->uuidFromBytes($authDataStream->take(16));
The new helper uuidFromBytes() formats 16 bytes into a hyphenated lowercase UUID string:
private function uuidFromBytes(string $bytes): string
{
$uuid = bin2hex($bytes);
return strtolower(sprintf(
'%s-%s-%s-%s-%s',
substr($uuid, 0, 8),
substr($uuid, 8, 4),
substr($uuid, 12, 4),
substr($uuid, 16, 4),
substr($uuid, 20, 12)
));
}
This ensures the value matches the 36-character switch case in createFromArray().
The fix was introduced in commit 63949c36b (“Fix WebAuthn\CustomServer credential save”), authored on September 28, 2026, and committed to QA_5_2 on September 29, 2026.
It was merged into master on September 30, 2026, via merge commit 1a7bf606.
The same commit updated unit tests to expect UUID strings rather than raw binary bytes:
- 'aaguid' => hex2bin('00000000000000000000000000000000'),
+ 'aaguid' => '00000000-0000-0000-0000-000000000000',
Prior test cases used hex2bin() on all zeros, which produced valid UTF-8 and allowed json_encode() to pass unnoticed.
Status across reference branches:
| Reference | uuidFromBytes present | aaguid format | Status |
|---|---|---|---|
RELEASE_5_2_1 (Feb 8, 2023) | No | Raw 16 bytes | Unpatched |
RELEASE_5_2_2 (Jan 21, 2025) | No | Raw 16 bytes | Unpatched |
RELEASE_5_2_3 (Oct 8, 2025) | No | Raw 16 bytes | Unpatched |
QA_5_2 (as of Oct 29, 2026) | Yes | 36-char UUID string | Patched |
master (as of Oct 29, 2026) | Yes | 36-char UUID string | Patched |
WebAuthn support was introduced on December 19, 2022 (commit f143d2a70), debuting in release 5.2.1.
CustomServer.php does not exist in 5.2.0.
As of October 29, 2026, this fix has not shipped in any tagged release; 5.2.3 remains the latest official build.
Note that the signature verification bypass discussed in our previous article remains unpatched in QA_5_2 and master.
Commit 63949c36b only addresses the aaguid encoding format.
Environments affected by this flaw
This defect only manifests in environments where CustomServer runs.
| Distribution source | web-auth/webauthn-lib | Active server | Vulnerable to this flaw |
|---|---|---|---|
| Official website tarball / zip (5.2.1 to 5.2.3) | Bundled | WebauthnLibServer | Not affected |
| Official Docker image | Bundled | WebauthnLibServer | Not affected |
Git tag RELEASE_5_2_3 via composer install --no-dev | Installed | WebauthnLibServer | Not affected |
Git QA_5_2 via composer update then --no-dev | Excluded | CustomServer | Affected prior to Sep 29, 2026; patched after |
Git master via composer update then --no-dev | Excluded | CustomServer | Affected prior to Sep 30, 2026; patched after |
Library dependency retention across release tags
web-auth/webauthn-lib is declared in composer.json under require-dev across all branches.
As a development dependency, running composer install --no-dev would normally exclude it.
However, release tarballs and release tags include a pre-generated composer.lock where package placement differs:
| Reference | composer.lock present | Location of web-auth/webauthn-lib | Result with --no-dev |
|---|---|---|---|
RELEASE_5_2_1 | Yes | packages (v3.3.12) | Installed |
RELEASE_5_2_2 | Yes | packages (v3.3.12) | Installed |
RELEASE_5_2_3 | Yes | packages (v3.3.12) | Installed |
QA_5_2 | No | — | Excluded |
master | Yes | packages-dev (v5.3.9) | Excluded |
composer install strictly follows composer.lock. Because web-auth/webauthn-lib is listed under the production packages section in release tags, --no-dev still installs it.
On development branches without a lockfile or where it resides in packages-dev, the library is omitted, so CustomServer runs.
How to check and remediate
To verify which server implementation is active, check for the presence of the library file:
ls vendor/web-auth/webauthn-lib/src/Server.php
If the file exists, the installation uses WebauthnLibServer and is unaffected. If absent, CustomServer is active.
If key registration fails with Internal error: TypeError, installing the official library resolves the problem.
Run composer require web-auth/webauthn-lib:^3.3.2 on 5.2.x branches or ^5.3 on master.
When enabling the library, ensure either the bcmath or gmp PHP extension is loaded. Neither is declared as a hard requirement in composer.json, but signature validation will fail without one of them.
If credentials were previously registered under CustomServer, they will trigger UrlException once the library is active.
Because the 2FA management screen will crash before loading, users cannot delete broken keys from the UI.
Administrators must either manually repair config_data in pma__userconfig or clear the user’s two-factor configuration row.
To repair the record, convert the 16 binary bytes of aaguid into a 36-character hyphenated UUID string (for all zeros, 00000000-0000-0000-0000-000000000000).
Deploying QA_5_2 (post-September 29, 2026) or master (post-September 30, 2026) resolves the AAGUID bug within CustomServer, but switching to the official library is recommended because CustomServer does not verify cryptographic signatures.