# Authentication

**mTLS everywhere, no VPN.** Every leg of this integration, API calls and
webhook deliveries in both directions, is mutually authenticated with TLS
client certificates. On top of that, calls into Nexa carry a customer API
key, so the certificate says *who is calling* and the key says *on whose
behalf*.

## At a glance

| Leg | Direction | Transport auth | Application auth |
| --- | --- | --- | --- |
| Nexa → Tesser API calls | N→T | Nexa's client certificate, fingerprint-pinned | Identity resolved from the certificate fingerprint |
| Nexa → Tesser webhook posts | N→T | Nexa's client certificate | Resolved identity must match `{organizationId}` in the path |
| Tesser → Nexa API calls | T→N | Tesser's client certificate | `Authorization: Bearer <felix_api_key>`, cross-checked against the `customer` field |
| Tesser → Nexa webhook posts | T→N | Tesser client certificate (**committed build item**) | Ed25519 `X-Tesser-Signature` header |

## Nexa → Tesser (API calls and webhook posts)

This rides the Tesser platform's **existing** banking-partner mTLS design,
so there is nothing new to build on Tesser's side.

1. During onboarding, Nexa's client-certificate SHA-256 fingerprint is
   registered against Nexa's Tesser API key
   (`POST /v1/keys/{id}/certificates`).
2. The Tesser gateway terminates TLS, forwards the client-certificate
   fingerprint, and the platform resolves Nexa's identity from it.
3. **Rotation is overlapping**: register the new certificate, cut traffic
   over, then revoke the old one. There is no window in which Nexa has only
   one valid certificate registered.

The [webhook receiver](/api-tesser/webhooks#receive-nexa-webhook-op-4) <T />
adds one check on top: the identity resolved from the certificate must match
the `{organizationId}` in the request path. A mismatch is a `401`.
`{organizationId}` is Nexa's Tesser organization ID, shared during
onboarding.

Before sending real events, Nexa can confirm connectivity and credentials
with [`HEAD /v1/webhooks/nexa/{organizationId}`](/api-tesser/webhooks#verify-webhook-connectivity-op-4)
<T />, which uses the same path and the same auth, with no body.

The same certificate authenticates Nexa's treasury calls:
[`GET /v1/accounts/{id}`](/api-tesser/accounts#get-an-account-by-id) <T /> and
[`POST /v1/treasury/withdrawals`](/api-tesser/treasury#create-withdrawal)
<T />.

## Tesser → Nexa (API calls)

Tesser presents **two** credentials on every call to a Nexa-hosted endpoint:

1. A **client certificate** identifying Tesser as the orchestrator.
2. `Authorization: Bearer <felix_api_key>`, the **customer's** Nexa API key,
   identifying the customer context. The `customer` field in the request body
   cross-checks it.

Per-customer credentials mean onboarding a second remitter later needs no
auth redesign: a new key, not a new scheme.

**Recommendation to Nexa for validating Tesser's certificate:** pin the leaf
fingerprint (the mechanism Tesser itself uses) *or* validate against a
Tesser-provided issuing CA. Either is acceptable; it is Nexa's choice, and
both are documented here deliberately so the decision can be made in the
technical meeting ([open question 8](/open-questions)).

| Approach | What Nexa stores | Rotation |
| --- | --- | --- |
| **Leaf fingerprint pinning** | The SHA-256 fingerprint of Tesser's leaf certificate | Tesser sends the new fingerprint ahead of the cutover; Nexa holds both registered during the overlap, then drops the old one |
| **Issuing-CA validation** | Tesser's issuing CA certificate | Leaf rotation needs no action from Nexa as long as the CA is unchanged |

Certificate exchange and the rotation procedure itself are **out-of-band
onboarding**, not API operations. Both sides should agree the contact channel
and the overlap window before the first live GTQ moves.

Operations affected: [create quote](/api-nexa/quotes#create-quote-op-1),
[accept quote](/api-nexa/quotes#accept-quote-op-2),
[report deposit](/api-nexa/deposits#report-usdc-deposit-op-3),
[create payment](/api-nexa/payments#create-payment-op-5),
[get quote](/api-nexa/quotes#get-quote-optional-op-8),
[get payment](/api-nexa/payments#get-payment-optional-op-9). All are <N />.

## Tesser to Nexa (webhooks): committed build item

Tesser's webhook sender today signs payloads with Ed25519 (the
`X-Tesser-Signature` header) over plain HTTPS and **cannot currently present
a client certificate**. Tesser will extend it to present a client certificate
for Nexa deliveries, so webhooks meet the same mTLS bar as API calls. The
Ed25519 signature header is retained. It is already built, and belt and
braces costs nothing.

This is Tesser platform engineering, on the POC critical path, and it is
tracked as such. It is needed before live webhooks; it is **not** needed for
the stub host or this portal. It affects deliveries to
[`POST /v1/webhooks/tesser`](/api-nexa/webhooks#receive-tesser-webhook-nexa-implements-this)
<N />.

## Error behaviour

Authentication failures use the shared error envelope with
`error_code: "UNAUTHENTICATED"` and HTTP `401`, on both sides and on every
operation:

```json
{
  "errors": [
    {
      "error_code": "UNAUTHENTICATED",
      "error_message": "Client certificate fingerprint is not registered."
    }
  ],
  "request_id": "req_01JA8F3K2M7Q"
}
```

The vendored Tesser core-API operations ([get account](/api-tesser/accounts#get-an-account-by-id),
[create withdrawal](/api-tesser/treasury#create-withdrawal)) use the core
API's own error codes instead, with `api-0004` for a 401. That difference is
intentional: those endpoints are already live and their contract is not being
redefined for this POC.

## The host

One environment for now: a single host serves all three API documents,
separated by path prefix.

| | |
| --- | --- |
| **Nexa-hosted API** | `https://nexa-poc-main-c82e5ac.zuplo.app/nexa` |
| **Tesser-hosted API** | `https://nexa-poc-main-c82e5ac.zuplo.app/tesser` |

Today that host is a stateless stub: no mTLS, no API keys, no sequencing.
The auth model above describes the real implementations, not the stub. See
[Testing & stubs](/testing-stubs).
