---
title: JWT-ECDSA auth
description: Per-request ES256 JWT signing used by Coinbase Developer Platform and similar APIs — KEY NAME / PEM fields, validation round-trip, and runtime header injection.
---

import { Callout } from "fumadocs-ui/components/callout";

# JWT-ECDSA auth

<VersionBadge pkg="@codespar/api-types" />

`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](/docs/concepts/authentication#configuring-provider-credentials) 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:

| 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

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`](https://codespar.dev/dashboard/auth-configs) (or [`/dashboard/connections`](https://codespar.dev/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.

<Callout type="info">
**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 account/project scope and the runtime will reuse the imported key across all three catalog rows.
</Callout>

## 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 request** — `sub` 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 account/project scope rules in [Authentication](/docs/concepts/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:

```json
{
  "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. |

<Callout type="warn">
**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 `401`s in clusters; the symptom looks like a key problem but is almost always time.
</Callout>

## Next steps

<NextStepsGrid items={[
  { label: "CONCEPT", title: "Authentication", description: "The full seven-auth-type taxonomy and where jwt_ecdsa sits within it.", href: "/docs/concepts/authentication" },
  { label: "CONCEPT", title: "HMAC-signed auth", description: "Per-request HMAC signing used by Foxbit and other LATAM crypto exchanges.", href: "/docs/concepts/hmac-signed-auth" },
  { label: "CONCEPT", title: "mTLS / cert auth", description: "X.509 client-certificate flow used by BR open-banking corporate APIs.", href: "/docs/concepts/cert-auth" },
]} />
