Skip to main content

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.

3 min read · updated

JWT-ECDSA auth

@codespar/api-typesv0.5.0

jwt_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, and coinbase-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:

FieldVisibilityWhat it is
key_nameVisibleThe 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_pemMasked entirelyPEM-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

  1. 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.
  2. Operator opens /dashboard/auth-configs (or /dashboard/connections when scoping per project).
  3. Picks the jwt_ecdsa-typed server — for example, coinbase-cdp-trading.
  4. 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).
  5. 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.
  6. On a green response, operator clicks Save — the vault writes key_name as 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:

  1. Pulls key_name and private_key_pem from the vault and imports the PEM into a KeyObject. The imported key is cached per (orgId × serverId × envFingerprint) tuple, mirroring the undici.Agent cache used for cert — the PEM parse is paid once per tuple, not per request.
  2. Computes the JWT claims for this specific requestsub and kid from key_name, iss: "cdp", aud: ["cdp_service"], nbf: now, exp: now + 120, and uri: "<METHOD> <host><path>" resolved from the catalog base_url plus the resolved tool path. A random 16-byte hex nonce goes in the JWT header alongside alg: "ES256", typ: "JWT".
  3. Signs the JWT with the cached KeyObject.
  4. 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 code

The 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

SymptomLikely causeFix
invalid_pem on ValidateThe 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 callkey_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 keyVerify 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 codeThe 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 failingClock 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

Edit on GitHub

Last updated on