Tech14 min read

phpMyAdmin 5.2 WebAuthn Key Registration Fails with TypeError Due to Raw AAGUID Bytes

IkesanContents

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”.

AttributeCustomServer setupLibrary setup
phpMyAdmin5.2.35.2.3
SourceGit tag RELEASE_5_2_3Official website tarball
web-auth/webauthn-libNone (removed vendor/web-auth)v3.3.12
Active WebAuthn ServerCustomServerWebauthnLibServer
PHP8.2.358.2.35 (with bcmath and gmp)
SchemeHTTPHTTPS (self-signed certificate)
DatabaseMySQL 8.0MySQL 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.type must equal webauthn.create
  • The challenge must match the session challenge
  • The origin host must match the requested host
  • The first 32 bytes of authData must match the SHA-256 hash of the host
  • The User Present (UP) flag bit in authData must be set
  • attestationObject.fmt must be none with an empty attStmt

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:

AAGUIDContains bytes >= 0x80Valid UTF-8Resultconfig_data written
00000000000000000000000000000000NoYesSuccess605 bytes written
2fc0579f814410bd0000000000000000YesNoFailedNo write occurred
cb69481e8ff7403993ec0a2729a154a8YesNoFailedNo write occurred
6028b017b1d44c02b4b3afcdafc96bb2YesNoFailedNo 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:

MetricCountPercentage
Generated UUIDs200,000100%
Contains bytes >= 0x80200,000100%
Valid UTF-8 byte sequences160.0080%
json_encode() failures199,98499.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 aaguidLengthPassword Auth Result2FA Screen Status
Raw 16 bytes (saved by CustomServer)16 bytesInternal error: Safe\Exceptions\UrlException: An error occuredNever displayed
00000000-0000-0000-0000-00000000000036 chars2FA prompt displayedSuccess 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:

ReferenceuuidFromBytes presentaaguid formatStatus
RELEASE_5_2_1 (Feb 8, 2023)NoRaw 16 bytesUnpatched
RELEASE_5_2_2 (Jan 21, 2025)NoRaw 16 bytesUnpatched
RELEASE_5_2_3 (Oct 8, 2025)NoRaw 16 bytesUnpatched
QA_5_2 (as of Oct 29, 2026)Yes36-char UUID stringPatched
master (as of Oct 29, 2026)Yes36-char UUID stringPatched

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 sourceweb-auth/webauthn-libActive serverVulnerable to this flaw
Official website tarball / zip (5.2.1 to 5.2.3)BundledWebauthnLibServerNot affected
Official Docker imageBundledWebauthnLibServerNot affected
Git tag RELEASE_5_2_3 via composer install --no-devInstalledWebauthnLibServerNot affected
Git QA_5_2 via composer update then --no-devExcludedCustomServerAffected prior to Sep 29, 2026; patched after
Git master via composer update then --no-devExcludedCustomServerAffected 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:

Referencecomposer.lock presentLocation of web-auth/webauthn-libResult with --no-dev
RELEASE_5_2_1Yespackages (v3.3.12)Installed
RELEASE_5_2_2Yespackages (v3.3.12)Installed
RELEASE_5_2_3Yespackages (v3.3.12)Installed
QA_5_2No—Excluded
masterYespackages-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.