Skip to main content

mTLS / cert auth

How CodeSpar handles X.509 client-certificate authentication for BR open-banking corporate APIs — upload, runtime, rotation, expiry warnings.

1 min read · updated

mTLS / cert auth

@codespar/api-typesv0.4.0

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 — 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.

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.

Files the operator uploads

FieldFormatWhat it does
certPEM client certificate (.pem or .crt)Identifies your application to the bank. Must match the CN/CNPJ that was registered during onboarding.
keyPEM private key (.key, .pem) — RSA or ECDSAPair to the client cert. Never leaves the vault. CodeSpar never logs it, never echoes it back through the API.
caPEM 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 (or /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:

{
  "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:

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

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.

Troubleshooting

SymptomLikely causeFix
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 chainRe-download the issued bundle and re-upload all three files together.
401 after a successful TLS connectionCompanion API-key header (Bradesco style) is missing or wrong — TLS succeeded, but the application-layer auth failedVerify the dev key in the dashboard matches the one issued by the bank's developer portal.
CertExpiryBadge is red and calls 4xxCert 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 recognizeWrong PEM uploaded — likely the bank issued multiple certs and the wrong one was usedRe-upload the correct cert; verify the CN matches the CNPJ on file.

Next steps

Edit on GitHub

Last updated on