JWT-ECDSA auth
Per-request ES256 JWT signing used by Coinbase Developer Platform and similar APIs — KEY NAME / PEM fields, validation round-trip, and runtime header injection.
JWT-ECDSA auth
@codespar/api-typesv0.5.0jwt_ecdsa is the auth shape where every outbound request carries a freshly minted, short-lived JSON Web Token signed with an ECDSA P-256 private key. The credential the operator stores is asymmetric — only the private key — and the proxy executor mints a fresh ES256 JWT per call rather than presenting a static bearer.
This sits next to hmac_signed in spirit (per-request cryptographic proof, not a reusable token) but differs on two axes. It is asymmetric: the provider holds only the public half, and the operator holds the private key — there is no shared secret to leak. It is also per-request signed at the application layer, not at the TLS handshake — the runtime never opens a custom TLS context (compare cert), it just stamps an Authorization: Bearer <jwt> header on a normal HTTPS call. It rounds out the seven-type taxonomy alongside api_key, path_secret, oauth, cert, hmac_signed, and none.
When you'll use it
- Coinbase Developer Platform (CDP) — production today. A single CDP API key authenticates against three catalog rows:
coinbase-cdp-trading,coinbase-cdp-wallets, andcoinbase-cdp-payments. - Future providers with the same shape — any API that hands you an ECDSA P-256 private key and asks for a per-request ES256 JWT. Apple Push (APNs) and a handful of bank APIs follow the same pattern with different claim sets; each lands as a catalog drop, not a runtime change.
The defining property of this pattern: the provider gives you a private key in PEM form plus an opaque key identifier that names the key inside their system. The wire credential is a fresh JWT every call.
Field shape
The operator stores two values in the vault:
| Field | Visibility | What it is |
|---|---|---|
key_name | Visible | The provider's identifier for the key. For CDP this is organizations/<org>/apiKeys/<id>. Sent in cleartext as the JWT's sub and kid. |
private_key_pem | Masked entirely | PEM-encoded ECDSA P-256 private key. Never leaves the vault. Used only to sign the per-request JWT. |
The dashboard's connect modal renders a single-line input for key_name and a multi-line textarea (with file-upload affordance) for private_key_pem. Visible-vs-masked is driven by per-field metadata on the catalog row, mirroring how hmac_signed distinguishes KEY from SECRET.
Connect flow
- Operator opens
portal.cdp.coinbase.com(or the equivalent developer portal for a future provider), mints an API key, and downloads the PEM private key plus the displayed key name. - Operator opens
/dashboard/auth-configs(or/dashboard/connectionswhen scoping per project). - Picks the jwt_ecdsa-typed server — for example,
coinbase-cdp-trading. - The Provider Connect modal renders two fields with the right per-field badges (KEY NAME visible, PEM masked). The PEM input accepts both paste-from-clipboard and file upload (
.pem,.key). - Operator clicks Validate key. The dashboard fires
POST /v1/connections/jwt-validate(admin-key route), which:- Imports the PEM into a Node
crypto.KeyObject. - Mints a sample JWT against the provider's expected claim shape.
- Returns
{ ok: true }on a green import, or the exact import error (invalid_pem,unsupported_curve, etc.) on failure. A companion PR upgrades this to a real upstream ping against CDP.
- Imports the PEM into a Node
- On a green response, operator clicks Save — the vault writes
key_nameas a visible ref and stores the PEM encrypted at rest. On a red response, the dashboard surfaces the error inline so the operator can fix the input before persisting.
One CDP key, three products. The same key_name + PEM pair authenticates against coinbase-cdp-trading, coinbase-cdp-wallets, and coinbase-cdp-payments. Connect once at the org/project scope and the runtime will reuse the imported key across all three catalog rows.
What happens at runtime
On every outbound call to a jwt_ecdsa provider, the proxy executor:
- Pulls
key_nameandprivate_key_pemfrom the vault and imports the PEM into aKeyObject. The imported key is cached per(orgId × serverId × envFingerprint)tuple, mirroring theundici.Agentcache used forcert— the PEM parse is paid once per tuple, not per request. - Computes the JWT claims for this specific request —
subandkidfromkey_name,iss: "cdp",aud: ["cdp_service"],nbf: now,exp: now + 120, anduri: "<METHOD> <host><path>"resolved from the catalogbase_urlplus the resolved tool path. A random 16-byte hexnoncegoes in the JWT header alongsidealg: "ES256",typ: "JWT". - Signs the JWT with the cached
KeyObject. - Stamps
Authorization: Bearer <jwt>on the outbound request.
session.execute("coinbase-cdp-trading/order_create", { ... })
│
▼
proxy-executor.ts
│
├─ resolve connection → { key_name, private_key_pem }
├─ envFingerprint = hash(orgId, serverId, env, key fingerprint)
├─ KeyObject cache hit? → reuse
│ ↓ miss
│ crypto.createPrivateKey(pem)
├─ now = Math.floor(Date.now() / 1000)
├─ claims = { sub: key_name, iss: "cdp", aud: ["cdp_service"],
│ nbf: now, exp: now + 120,
│ uri: `${method} ${host}${path}` }
├─ header = { alg: "ES256", typ: "JWT", kid: key_name, nonce: randomHex(16) }
├─ jwt = sign(header, claims, KeyObject)
├─ headers["Authorization"] = `Bearer ${jwt}`
▼
HTTPS request — provider verifies signature with the public half
│
▼
2xx → normalized response 4xx → bubbled up with provider error codeThe JWT is valid for 120 seconds. There is no body signing, no separate timestamp header, no nonce-tracking on the provider — the JWT carries everything it needs in nbf / exp. Replay risk is bounded by the 2-minute window; clock-skew tolerance is bounded by the same.
One CDP key, three products
CDP issues a single API key per developer account that authenticates against all of their products. CodeSpar models this by letting one connection feed multiple catalog rows: the operator connects coinbase-cdp-trading once, and the same vault entry is reused when the runtime resolves credentials for coinbase-cdp-wallets and coinbase-cdp-payments (subject to the org/project scope rules in Authentication).
The trade-off is operator clarity: the dashboard shows three separate catalog rows so the meta-tool router can pick a specific product, but the connection is logically one. Each row's auth_type: "jwt_ecdsa" declaration shares the same secret_refs, so the proxy resolves to the same key_name + PEM regardless of which row triggered the call.
Catalog spec snippet
A minimal jwt_ecdsa provider declaration looks like this:
{
"id": "coinbase-cdp-trading",
"name": "Coinbase CDP — Trading",
"auth_type": "jwt_ecdsa",
"secrets": [
{ "name": "key_name", "kind": "key", "label": "Key name", "visibility": "visible" },
{ "name": "private_key_pem", "kind": "secret", "label": "Private key (PEM)", "visibility": "masked" }
],
"jwt": {
"algorithm": "ES256",
"issuer": "cdp",
"audience": ["cdp_service"],
"ttl_seconds": 120,
"uri_claim": "${method} ${host}${path}"
}
}The jwt block is the per-provider knob. Different providers want different issuer values, audiences, TTLs, or claim shapes — the runtime reads everything off the catalog row and never assumes. Onboarding a new JWT-ECDSA provider is reading their docs, transcribing the claim recipe into the catalog spec, and confirming with a validation round-trip.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
invalid_pem on Validate | The pasted blob is not a PEM private key, or it's the wrong curve. CDP issues PKCS8-encoded ECDSA P-256; P-384, secp256k1, and RSA keys won't import. | Re-download the key from portal.cdp.coinbase.com. If you have an older OpenSSL-format key, convert with openssl pkcs8 -topk8 -nocrypt -in old.pem -out new.pem. |
auth_failed from the provider on every call | key_name is malformed (must be organizations/<org>/apiKeys/<id>), the key has been revoked at CDP, or the imported PEM doesn't pair with the named key | Verify the key still exists in the CDP portal. Re-copy key_name exactly — leading/trailing whitespace breaks the JWT sub. |
401 with no specific error code | The JWT's uri claim doesn't match the request path. Usually means the catalog base_url is wrong or a tool definition has a stale path. | Check the catalog row's base_url against the provider's current docs. The runtime computes uri from the resolved URL, so any drift here misfires. |
| Calls work for a few minutes then start failing | Clock drift on the proxy host > 2 minutes. The JWT's exp is nbf + 120, so any drift outside that window invalidates fresh tokens. | Enable NTP on the host. This is common on virtualized infra without a clock-sync agent. |
Watch the clock. The JWT is short-lived (120 s), so even modest clock drift between the proxy host and the provider's edge will start failing requests. Health-check NTP if you ever see intermittent 401s in clusters; the symptom looks like a key problem but is almost always time.
Next steps
Last updated on
HMAC-signed auth
Per-request HMAC signature auth used by Foxbit and other LATAM crypto exchanges — KEY/SECRET fields, validation round-trip, and runtime header injection.
Connect Links
Hosted OAuth flow that lets your end users connect their own Stripe, Mercado Pago, Shopify, and other provider accounts — without you building or maintaining a connection UI.