# Access

## Introduction

Caller authorization always runs **before** a concurrency slot is taken.
Configure caller auth on the pool's **Access** tab. Upstream secrets for managed
pools live under [Credentials](/docs/credentials).

Checks run in this order: IP allowlist → JWT / Cloudflare Access → pool key →
HMAC (when required).

## Pool keys

Create keys under **Pools → Access → Create key**. Send the secret with
`X-Nthpool-Key`:

```bash
curl -sS "https://your-pool.nthpool.cloud/v1/records" \
  -H "X-Nthpool-Key: $POOL_KEY"
```

On **managed** pools you may instead send
`Authorization: Bearer $POOL_KEY` or `Authorization: Nthbouncer $POOL_KEY`.

On **pass-through** pools, always use `X-Nthpool-Key` for the pool key so
`Authorization` can carry the upstream credential.

Missing or invalid keys return `401`.

### Write blocking

Each key either **blocks writes** (default) or has **writes allowed**. With
writes blocked, the key admits `GET`, `HEAD`, and `OPTIONS` only. Every other
method returns `403` before the request reaches your origin, and before it
takes a concurrency slot.

On **Access**, under the pool keys, you can add methods this origin uses for
reads. SuiteQL and GraphQL are `POST`. Paths belong to that method only. Leave
a method's paths blank to allow it everywhere, or list prefixes
(`/services/rest/query/`) so other POSTs stay blocked.

Leave writes blocked for callers that only fetch: retrieval agents, dashboards,
search indexers. It means a prompt injection or a buggy loop cannot delete a
record no matter what it sends.

Keys minted before this option existed have writes allowed and are unchanged.

The lock API is not affected: acquiring, renewing, and releasing a concurrency
slot are coordination calls, not writes to your origin, so a writes-blocked key
can still do them.

On a [database pool](/docs/pools/database) the HTTP method is not the write
gate. Every query is a `POST`, including a read. The write boundary is the
Postgres role the pool connects as. In v1 that is always the read role, so a
writes-allowed key and a writes-blocked key connect the same way. A statement
that tries to write is refused by the database (`403`, SQLSTATE `25006`), not
by the key's method list. MCP query tools (`pool_query`, `pool_schema`,
`pool_count_rows`) are read-only; there is no write tool to hide.

## Inbound HMAC (optional)

When **Require signed requests** is enabled on Access, each key gets a signing
secret (`nbsk_…`, shown once). Callers must send:

| Header | Value |
|--------|-------|
| `X-Nthpool-Timestamp` | Unix timestamp (seconds) |
| `X-Nthpool-Nonce` | Unique nonce for the request |
| `X-Nthpool-Signature` | Hex HMAC-SHA256 over the canonical string |

Canonical string (fields joined with `\n`):

```text
METHOD
PATH
QUERY
SHA256(BODY)
TIMESTAMP
NONCE
```

`PATH` is the request path (no host). `QUERY` is the query string without `?`
(empty string when there is no query). `SHA256(BODY)` is the hex SHA-256 of the
raw body bytes (empty body → SHA-256 of empty input).

### Limits

| Limit | Behavior |
|-------|----------|
| Clock skew ±**300** seconds | Outside the window → `401` |
| Nonce uniqueness | Replay within the window → `401` |
| Body size **10 MB** | Larger bodies → `413 payload_too_large` (signing must hash the whole body) |

HMAC-signed requests are not eligible for the
[response cache](/docs/pools/concurrency#response-cache).

## Origin verification (outbound HMAC)

Enable **Origin verification** on **Pools → Access**. The pool then signs
**every request it forwards** so your origin can prove traffic came through the
pool and reject direct callers.

Outbound signing uses the **same header names** as inbound HMAC, but a
**different** canonical string: it hashes only the first **1 MB** of the body
and binds `Content-Length`, so large uploads can stream. The secret is a
pool-level `osk_…` value (shown once when you enable or rotate).

### What the pool sends upstream

After stripping every inbound `X-Nthpool-*` header (callers cannot forge these),
the pool adds:

| Header | Value |
|--------|-------|
| `X-Nthpool-Timestamp` | Fresh epoch seconds |
| `X-Nthpool-Nonce` | Fresh UUID (new on every attempt, including retries) |
| `X-Nthpool-Signature` | Hex HMAC-SHA256 of the outbound canonical string with `osk_…` |

Canonical string (fields joined with `\n`):

```text
METHOD
PATH
QUERY
SHA256(BODY_PREFIX)
CONTENT_LENGTH
TIMESTAMP
NONCE
```

`PATH` / `QUERY` are what the origin receives (forwarded verbatim).
`BODY_PREFIX` is the first `min(body length, 1 MiB)` raw bytes (empty body →
SHA-256 of empty input). `CONTENT_LENGTH` is the decimal full body length the
pool forwards (`0` when empty).

### Limits

| Limit | Behavior |
|-------|----------|
| Prefix window **1 MB** | Only the first mebibyte is hashed; the rest streams |
| Declared `Content-Length` on large bodies | Preferred; bodies over 1 MB stream without a 10 MB reject |
| No `Content-Length` and body &gt; **10 MB** | `413 payload_too_large` (length cannot be bound without buffering) |
| Clock skew at origin | Match ±**300** seconds |

Nonce replay defense at the origin is optional (the pool already mints a fresh
nonce per attempt). Retry-on-429 still needs a re-readable body under **10 MB**;
larger bodies with a declared length run as a single streamed attempt and are
still prefix-signed.

### Rotation

Mint a new secret, update the origin to accept both old and new, then
**Activate** in the dashboard. Discard a pending secret if you abort the
rotation.

Worked verification code:
[Determine a request came from the proxy](/docs/knowledge-base/origin-verification).

## Caller JWT (plan-gated)

Configure a JWKS URL (and optional issuer / audience) on Access. Prefer the
`X-Nthpool-Jwt` header. On pass-through pools, a bearer JWT may also be
accepted in `Authorization` when that does not conflict with upstream auth
needs.

## Cloudflare Access (plan-gated)

Configure team domain and application audience on Access. Send the Access JWT
as `Cf-Access-Jwt-Assertion` or `X-Nthpool-Jwt`.

Worked setup:
[Configure Cloudflare Zero Trust](/docs/knowledge-base/cloudflare-zero-trust).

## IP allowlists (plan-gated)

Attach an [IP policy](/docs/ip-policies) to the pool. Requests from addresses
outside the allowlist receive `403` before other auth checks. Policy edits take
effect within a few seconds.

## Next steps

- [Determine a request came from the proxy](/docs/knowledge-base/origin-verification)
- [Pools](/docs/pools)
- [IP Policies](/docs/ip-policies)
- [Quickstart](/docs/quickstart)
