← Blog · September 2, 2026
Authenticating to the Coinbase CDP facilitator from PHP: Ed25519 JWTs without gymnastics
The problem
The CDP x402 facilitator (/verify, /settle) requires a JWT signed with your CDP API secret. Coinbase's SDKs cover TypeScript and Python; if your seller runs on PHP (ours is Laravel on shared hosting), you sign the JWT yourself. Two key types exist depending on when and how the key was created:
- Ed25519 (newer "secret API keys"): the secret is base64, 32 or 64 bytes decoded — sign with EdDSA, header
alg: EdDSA. - ECDSA P-256 (PEM,
-----BEGIN EC PRIVATE KEY-----): sign with ES256 — and OpenSSL gives you a DER signature that must be converted to the raw 64-byte r||s form JWTs use.
Ed25519 with libsodium
$decoded = base64_decode($secret);
// CDP may give you the 32-byte seed or the full 64-byte secret key
$secretKey = match (strlen($decoded)) {
SODIUM_CRYPTO_SIGN_SECRETKEYBYTES => $decoded, // 64 bytes
SODIUM_CRYPTO_SIGN_SEEDBYTES => sodium_crypto_sign_secretkey(
sodium_crypto_sign_seed_keypair($decoded) // 32-byte seed
),
default => throw new RuntimeException('Unexpected Ed25519 key length'),
};
$signature = sodium_crypto_sign_detached($signingInput, $secretKey);
Two production pitfalls:
- On shared hosting, the
sodiumextension is often not enabled by default — you getUndefined constant SODIUM_CRYPTO_SIGN_SECRETKEYBYTES. Enable it in cPanel → Select PHP Version → Extensions, and guard withfunction_exists('sodium_crypto_sign_detached')to fail with a helpful message instead of a fatal. - Handle both 32- and 64-byte secrets. Which one you have depends on where you copied the key from.
ES256: convert DER to raw r||s
openssl_sign($signingInput, $der, $privateKey, OPENSSL_ALGO_SHA256);
// JWT ES256 wants raw r||s (64 bytes), OpenSSL gives ASN.1 DER — unpack it:
$offset = 4; // SEQUENCE header + INTEGER header
$rLen = ord($der[3]);
$r = ltrim(substr($der, $offset, $rLen), "\x00");
$offset += $rLen + 2;
$sLen = ord($der[$offset - 1]);
$s = ltrim(substr($der, $offset, $sLen), "\x00");
$signature = str_pad($r, 32, "\x00", STR_PAD_LEFT)
. str_pad($s, 32, "\x00", STR_PAD_LEFT);
The JWT claims CDP expects
$header = ['alg' => $alg, 'kid' => $apiKeyId, 'typ' => 'JWT', 'nonce' => bin2hex(random_bytes(16))];
$claims = [
'sub' => $apiKeyId,
'iss' => 'cdp',
'aud' => ['cdp_service'],
'nbf' => time(),
'exp' => time() + 120,
'uris' => [$method.' '.$host.$path], // e.g. "POST api.cdp.coinbase.com/platform/v2/x402/settle"
];
One more Laravel-specific trap: if you read the key with env() it will return null after php artisan config:cache. Route every secret through config().
AgentBIT is a pay-per-call API platform for AI agents built on x402 —
no signup, no API keys, USDC on Base. Machine-readable entry points:
catalog.json ·
llms.txt ·
aggregated x402 discovery API ·
framework integrations.