---
title: mTLS / cert auth
description: How CodeSpar handles X.509 client-certificate authentication for BR open-banking corporate APIs — upload, runtime, rotation, expiry warnings.
---

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

# mTLS / cert auth

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

**Mutual TLS** authenticates both sides of an HTTPS handshake: the server presents its certificate as usual, and the client also presents one signed by a CA the server trusts. The connection only completes if both certs validate. There is no bearer token or OAuth flow on top — the cryptographic handshake itself is the credential.

CodeSpar's `cert` `auth_type` is the catalog shape for any provider that requires this pattern. It sits alongside `api_key`, `path_secret`, `oauth`, `hmac_signed`, and `none` in the [six-type taxonomy](/docs/concepts/authentication#configuring-provider-credentials) — the dashboard wizard knows to render a file-upload UX when a server's catalog row declares `auth_type: "cert"`, and the proxy executor knows to mount the PEM blob into an `undici.Agent` for outbound calls.

## When you'll use it

Brazilian open-banking corporate APIs are the canonical case. They mandate mTLS for any B2B integration and do not offer an API-key or OAuth alternative.

- **Banco do Brasil** — production pilot. Live rails: `cobranca_create`, `cobranca_status`.
- **Itaú, Santander, Bradesco, Caixa** — next batch. Each is a mechanical drop: a catalog row plus a `META_TOOL_CATALOG` entry; the runtime is the same.
- Future LATAM corporate-banking integrations (Mexico's SPEI corporate APIs, Chile's CMF-supervised APIs, etc.) will land on the same `cert` runtime.

<Callout type="info">
**BR open-banking note.** BCB's open-banking spec requires mTLS for the corporate (B2B) endpoints regardless of which institution you integrate with. The cert you upload is the institution's own dev/prod cert — issued to your CNPJ during onboarding with that bank.
</Callout>

## Files the operator uploads

| Field | Format | What it does |
|-------|--------|--------------|
| `cert` | PEM client certificate (`.pem` or `.crt`) | Identifies your application to the bank. Must match the CN/CNPJ that was registered during onboarding. |
| `key`  | PEM private key (`.key`, `.pem`) — RSA or ECDSA | Pair to the client cert. Never leaves the vault. CodeSpar never logs it, never echoes it back through the API. |
| `ca`   | PEM CA bundle (optional) | The bank's CA chain. Only required when the issuer is not in the system trust store; most BR institutions issue chains rooted in ICP-Brasil. |

All three are stored as PEM blobs in the CodeSpar vault, encrypted at rest. The cert + key pair stays bonded — re-uploading one without the other invalidates the connection.

## Connect flow walkthrough

1. Operator opens [`/dashboard/auth-configs`](https://codespar.dev/dashboard/auth-configs) (or [`/dashboard/connections`](https://codespar.dev/dashboard/connections) when scoping per project).
2. Picks the cert-auth-typed server — for example, `banco-do-brasil`.
3. The Provider Connect modal renders three file inputs (`cert`, `key`, optional `ca`), each accepting `.pem`, `.crt`, or `.key`.
4. Operator uploads the PEM blobs the bank issued. The dashboard does a client-side sanity check (file is non-empty, looks like a PEM header) before submitting.
5. Backend writes the PEM blobs to the vault, then runs `cert-parser.ts` to extract metadata via `node:crypto`'s `X509Certificate`:
   - subject CN
   - issuer
   - `validFrom` / `validTo`
   - SHA-256 fingerprint
6. Migration `0065_cert_metadata.sql` adds a `cert_metadata jsonb` column on `connected_accounts`; the parsed metadata lands there. The dashboard's `CertExpiryBadge` reads that column to surface expiry warnings — green default, **amber at 30 days**, **red at 7 days**, **critical when expired**.

## What happens at runtime

The proxy executor caches an `undici.Agent` per `(orgId × serverId × envFingerprint)` tuple. The first outbound call materializes the agent (parses PEM, builds the TLS context, opens the keepalive pool); every subsequent call within the same tuple reuses it. The TLS handshake is paid once, not per request.

```
session.execute("banco-do-brasil/cobranca_create", { ... })
  │
  ▼
proxy-executor.ts
  │
  ├─ resolve connection → cert + key + ca PEM blobs
  ├─ envFingerprint = hash(orgId, serverId, env, fingerprint)
  ├─ undici.Agent cache hit? → reuse
  │                           ↓ miss
  │                    new Agent({ connect: { cert, key, ca } })
  ▼
HTTPS request — TLS handshake reuses the cached pool
  │
  ▼
provider returns response → normalized → idempotency-keyed → returned to caller
```

Idempotency, retries, and the meta-tool transform layer behave identically to every other `auth_type`. The only thing that changes is which credential the proxy presents to the bank.

## Companion API-key header (Bradesco style)

A subset of BR banks layer a developer key alongside the mTLS handshake — the cert proves "you are this corporate entity," the dev key identifies which application within that entity is calling. Bradesco is the canonical example.

The catalog spec captures this with two extra fields:

```json
{
  "auth_type": "cert",
  "secrets": [
    { "name": "cert", "kind": "cert" },
    { "name": "key",  "kind": "cert" },
    { "name": "ca",   "kind": "cert", "optional": true }
  ],
  "path_secret_header_ref": "developer_key",
  "auth_header_name": "X-Bradesco-Developer-Key"
}
```

The dashboard reads those two extra fields and renders an additional non-cert input below the file uploaders. The proxy executor stamps the header on every call after the TLS connection is established. From the agent's perspective, nothing changes — the catalog row absorbs the wiring.

## Catalog snippet

A minimal cert-auth provider declaration looks like this:

```json
{
  "id": "banco-do-brasil",
  "name": "Banco do Brasil — Cobrança",
  "auth_type": "cert",
  "secrets": [
    { "name": "cert", "kind": "cert", "label": "Client certificate (PEM)" },
    { "name": "key",  "kind": "cert", "label": "Private key (PEM)" },
    { "name": "ca",   "kind": "cert", "label": "CA bundle (PEM)", "optional": true }
  ],
  "tools": ["cobranca_create", "cobranca_status"]
}
```

The `kind: "cert"` marker is what tells the dashboard to render a file uploader instead of a text input, and what tells the vault layer to treat the value as a multi-line PEM blob rather than a single-line secret.

## Cert rotation + revocation

Banks rotate corporate certs annually. The flow is:

1. Bank issues a renewed cert (and possibly a new CA chain) to the CNPJ during their renewal window.
2. Operator opens the connection in the dashboard and re-uploads the new PEM blobs.
3. Backend re-parses, writes new `cert_metadata` (new fingerprint, new `validTo`), and rewrites the vault entry.
4. The `envFingerprint` changes (because the cert fingerprint changed), which invalidates the cached `undici.Agent` automatically. The next outbound call materializes a fresh agent against the new cert.

For revocation — say a cert was leaked — the operator deletes the connection in the dashboard. The vault entry is wiped, the cached agent is dropped, and any in-flight calls fail closed with a clear error.

<Callout type="warn">
**Don't try to "edit" a cert in place.** Always re-upload the full PEM. CodeSpar derives the fingerprint from the file you submit; mutating only one field of the metadata silently desyncs the cache from the vault.
</Callout>

## Troubleshooting

| Symptom | Likely cause | Fix |
|---------|-------------|-----|
| `TLS handshake failed` (4xx at the network layer) | `key` is paired with a different `cert`, or the `ca` you uploaded does not match the bank's chain | Re-download the issued bundle and re-upload all three files together. |
| `401` after a successful TLS connection | Companion API-key header (Bradesco style) is missing or wrong — TLS succeeded, but the application-layer auth failed | Verify the dev key in the dashboard matches the one issued by the bank's developer portal. |
| `CertExpiryBadge` is red and calls 4xx | Cert is expired (or within the bank's grace window and they've started rejecting) | Renew with the bank, then re-upload the new PEM in the connect modal. |
| `cert_metadata` shows a CN you don't recognize | Wrong PEM uploaded — likely the bank issued multiple certs and the wrong one was used | Re-upload the correct cert; verify the CN matches the CNPJ on file. |

## Next steps

<NextStepsGrid items={[
  { label: "CONCEPT", title: "Authentication", description: "The full six-auth-type taxonomy and where cert sits within it.", href: "/docs/concepts/authentication" },
  { label: "CONCEPT", title: "HMAC-signed auth", description: "Per-request signature flow used by Foxbit and similar LATAM crypto APIs.", href: "/docs/concepts/hmac-signed-auth" },
  { label: "CONCEPT", title: "Connect Links", description: "Hosted OAuth flow for end-user provider connections.", href: "/docs/concepts/connect-links" },
]} />
