AgentBIT
← 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 sodium extension is often not enabled by default — you get Undefined constant SODIUM_CRYPTO_SIGN_SECRETKEYBYTES. Enable it in cPanel → Select PHP Version → Extensions, and guard with function_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.