Authentication and TLS
haltsql-server decides two things about every /v1/* request before it
looks at the SQL: who is calling and which context the verdict uses.
Both come from the caller's credentials, never from the prompt. This page
covers built-in TLS, client certificates (mTLS), OIDC/JWT bearer tokens and
static tokens. The configuration keys are listed in
config-reference.md; the HTTP behaviour in
http-api.md.
The request pipeline
- mTLS. When the connection presented a client certificate (already
verified against
server.tls.client_caby the TLS layer) and its identity matches aserver.auth.mtlsentry, that entry authenticates the request. A bearer token on the same request is ignored. A valid certificate that matches no identity falls through to step 2. - OIDC. A bearer token shaped like a JWT whose unverified
issequals a configured provider'sissueris verified by that provider only. A rejection is final (401, or503 AUTH_UNAVAILABLEwhen the provider's keys cannot be fetched). Tokens from other issuers, and opaque tokens, go to step 3. - Static tokens. The token's SHA-256 is compared in constant time with
every
server.auth.tokensentry.
Every identity is pinned to exactly one policy. A request that names another
policy gets 403 FORBIDDEN.
Context rules
| Caller | Context used for the verdict |
|---|---|
| mTLS identity | the identity's static context + body fields listed in its request_context_fields |
| OIDC token | values of context_claims + body fields listed in the provider's request_context_fields |
static token with context or request_context_fields | the token's static context + body fields listed in its request_context_fields |
| static token without either key, anonymous | the request body's context |
A body field outside request_context_fields is refused with
403 CONTEXT_NOT_ALLOWED; it is never silently dropped or overridden. A body
field that repeats an identity-provided field with exactly the same value is
accepted, so clients that always send their context keep working. The config
loader rejects request_context_fields that overlap context_claims or the
static mTLS context.
Built-in TLS
server:
listen: 0.0.0.0:8443
tls:
cert: /etc/haltsql/tls/tls.crt # chain, leaf first
key: /etc/haltsql/tls/tls.key
client_ca: /etc/haltsql/tls/ca.crt # enables client certificates
client_auth: optional # none | optional | required
min_version: "1.2" # or "1.3"
- TLS 1.2 and 1.3 only. TLS 1.2 is limited to ECDHE key exchange with AEAD ciphers (AES-GCM, ChaCha20-Poly1305); TLS 1.3 to AES-GCM and ChaCha20-Poly1305. Renegotiation is disabled. The handshake must finish within 10 seconds.
- The private key must match the certificate; startup (exit code 2) and reload fail otherwise.
client_auth: optionalasks for a certificate and verifies it when one is sent;requiredalso refuses handshakes without one. Verification is RFC 5280 path validation againstclient_caat the current time.- Rotation.
SIGHUP, or file watching (reload.watch), re-reads the certificate, key and client CA. New connections use the new material; existing connections are not interrupted. If the new files are broken or mismatched, the previous material stays active and the reload is counted inhaltsql_tls_reload_total{result="error"}. This works with cert-manager and Kubernetes secret volumes (the kubelet updates the files in place). Turning TLS on or off requires a restart. - Probes. With
client_auth: required, kubelet probes cannot present a certificate. Start the server with--health-listen 0.0.0.0:8081to serve only/healthzand/readyzover plain HTTP on a second port.
mTLS identities
server:
tls:
cert: /etc/haltsql/tls/tls.crt
key: /etc/haltsql/tls/tls.key
client_ca: /etc/haltsql/tls/ca.crt
client_auth: required
auth:
mtls:
identities:
- spiffe_id: spiffe://corp.example/ns/agents/sa/analytics
policy: saas-analytics
context: { tenant_id: acme }
- subject_cn: support-bot
policy: support-bot
request_context_fields: [tenant_id]
spiffe_idmatches the certificate's URI SAN exactly. Following the X.509-SVID rules, a certificate with more than one URI SAN has no SPIFFE ID and never matches aspiffe_identry.subject_cnmatches the subject common name exactly (case-sensitive). The CN is decoded from UTF8String, PrintableString, BMPString or UniversalString; a TeletexString, malformed, empty or control-character CN never matches (fail closed).- Server certificates that haltsql-server checks (OIDC issuers, bundle sources, OPA, audit sinks) are matched by host name against DNS SubjectAltName entries only: a certificate with the name only in its CN is rejected.
- The first matching identity in configuration order wins.
- Audit records carry
caller_type: "mtls"andsubjectset to the SPIFFE ID, or the CN.
With SPIRE, point client_ca at the trust bundle and have the agent workload
use its X.509-SVID as the client certificate.
OIDC / JWT bearer tokens
server:
auth:
oidc:
- name: corp-okta
issuer: https://corp.okta.com/oauth2/default
audiences: [haltsql]
# jwks_uri: discovered from <issuer>/.well-known/openid-configuration
algorithms: [RS256, ES256]
leeway_seconds: 60
jwks_cache_seconds: 300
policy_claim: haltsql_policy
allowed_policies: [saas-analytics, support-bot]
context_claims:
tenant_id: https://corp.example/tenant # a claim name containing dots
org_id: org.id # or a dot path
required_claims:
scope: sql.verify
request_context_fields: [user_id]
What is checked, in order:
| Check | Rule | Failure |
|---|---|---|
| shape | three base64url segments, JSON objects without duplicate members at any depth, no crit header, no "b64": false, at most 16 KiB | 401 malformed token |
| algorithm | header alg is in algorithms; none and every HMAC algorithm are always rejected | 401 |
| key | the header's kid names a JWKS key of the matching type (RSA for RS*/PS256, P-256 for ES256, P-384 for ES384, Ed25519 for EdDSA), with use absent or sig, key_ops absent or containing verify, and alg absent or equal; RSA keys must be 2048 to 8192 bits with an odd public exponent of at least 3; Ed25519 keys of small order, keys with private members or duplicate members, and symmetric keys are never loaded; header members that carry a key (jwk, jku, x5u, x5c) are ignored | 401 (missing or unknown kid) |
| signature | verified with that key | 401 |
iss | equals issuer exactly | 401 |
aud | a string, or an array of strings, containing one of audiences | 401 |
exp | required, a finite number; now < exp + leeway | 401 token has expired |
nbf, iat | if present, not later than now + leeway | 401 |
required_claims | a string must equal the claim, or be an element of an array claim, or a word of a space-separated claim such as scope; a list requires every value | 401 |
| policy | policy, or the string value of policy_claim if it is in allowed_policies, or the first policy_from_groups rule the token's groups match (see Policies from IdP groups) | 401 |
| context | each context_claims path must exist; strings and integral numbers become one value, non-empty arrays of those a set; anything else is rejected | 401 |
The sub claim is recorded as the audit subject; the token itself is never
logged or stored.
Key fetching and caching
- Discovery: when
jwks_uriis not set, it is read from<issuer>/.well-known/openid-configuration(a trailing/of the issuer is removed first). The document'sissuermust equal the configured issuer exactly; itsjwks_urimust behttps(orhttpwithallow_insecure_http, for tests) and carry no credentials. - HTTPS uses the system CA store and checks the host name. Responses are limited to 1 MiB and 5 seconds; redirects are not followed.
- Keys are fetched at startup and cached for
jwks_cache_seconds. - A token with an unknown
kidtriggers one refresh, at most once every 30 seconds per provider (key rotation without a restart, without letting randomkids hammer the IdP). - If a refresh fails, the cached keys stay usable for at most
min(jwks_cache_seconds, 3600)seconds past their expiry (stale-while-error). After that, every token from that provider gets503 AUTH_UNAVAILABLEuntil the IdP is reachable again. Failed refreshes are retried at most every 5 seconds. - A hot reload keeps the key cache of providers whose configuration did not change.
Choosing the policy
Pin a provider to one policy with policy: NAME when all its tokens belong
to one agent. Use policy_claim + allowed_policies when one IdP issues
tokens for several agents: the token cannot select a policy outside the
allowlist.
Use policy_from_groups when the IdP already sorts callers into groups or
roles: no custom claim is needed.
Policies from IdP groups
server:
auth:
oidc:
- name: corp-keycloak
issuer: https://sso.corp.example/realms/corp
audiences: [haltsql]
policy_from_groups:
claim: groups # default; a dot path such as realm_access.roles works
map: # ordered: the first entry the token matches wins
- { group: data-platform-admins, policy: admin-readonly }
- { group: support, policy: support-bot }
- { group: analysts, policy: saas-analytics }
default: null # no match: 401
How a verified token's policy is chosen:
- Overage check. If the token says its groups did not fit (Azure AD /
Entra ID "groups overage", below), it is rejected:
401, reasongroups_overage. - Lookup.
claimis looked up likecontext_claims: first as one claim name (so an Auth0 namespaced claimhttps://corp.example/groupsworks), then as a dot path through nested objects. A missing claim is401(missing_claim). - Shape. A string is one group and is never split on spaces; an array
must contain only strings. Anything else (a number, an object, an array
with a non-string) is
401(bad_claim). - First match. The
mapentries are tried in the order written, and the first one whosegroupthe token has wins, whatever the order of the groups inside the token. A caller in bothsupportandanalystsabove getssupport-bot, so list the most specific or most privileged groups first. Groups are compared exactly (case-sensitive). - Default. If nothing matches,
defaultis used. Without adefaultthe token is rejected:401, reasonno_group_policy(the message does not echo the token's groups).
The token is still pinned to the selected policy (a request naming another
policy gets 403 FORBIDDEN), and context_claims, required_claims and
request_context_fields work as for any OIDC provider.
| IdP | Claim to use | Notes |
|---|---|---|
| Okta | groups | Add a groups claim to the authorization server's access token, with a filter (for example "starts with haltsql-") so it stays small. |
| Keycloak | groups, or realm_access.roles / resource_access.<client>.roles | Add a Group Membership mapper to the client (or a client scope) with Full group path off, so groups are analysts and not /analysts; enable Add to access token. Realm and client roles are in the access token by default. Keycloak omits the claim for a user in no group, which is a 401 (missing_claim). |
| Auth0 | a namespaced claim, e.g. https://corp.example/groups | Set it from a post-login Action (api.accessToken.setCustomClaim). |
| Azure AD / Entra ID | groups (object ids) or roles (app roles) | See overage below. App roles (roles) are usually the better choice: they are scoped to the application and never overflow. |
Azure AD / Entra ID groups overage. When a user is in more groups than
fit in a token (200 for JWTs), Entra ID leaves the groups claim out and adds
_claim_names: {"groups": "src1"} and _claim_sources pointing at Microsoft
Graph (or, for implicit-flow tokens, hasgroups: true). HaltSQL never calls
Graph: a partial or missing group list could select the wrong policy, so such
a token is rejected with groups_overage, even if a groups claim is also
present. To avoid it, set groupMembershipClaims to ApplicationGroup and
assign only the relevant groups to the enterprise application, or map app
roles with claim: roles.
Group mapping is only as good as the IdP's group administration: anyone who
can add a user to data-platform-admins grants that user the
admin-readonly policy.
Static tokens
Static tokens remain supported. Store only token_sha256; each token is
pinned to one policy.
Bind each token to its tenant. Give the token a context and the request
body can no longer claim another tenant:
server:
auth:
tokens:
- name: acme-agent
token_sha256: "..."
policy: analytics-agent
context: { tenant_id: acme } # fixed for this token
request_context_fields: [user_id] # the body may still add these
A body field outside request_context_fields gets 403 CONTEXT_NOT_ALLOWED.
Use one token per tenant, or OIDC / mTLS when tenants are many.
A token with neither context nor request_context_fields is unbound: it
authenticates the calling service, and the request body supplies the whole
context. Any holder of an unbound token can obtain a verdict for any tenant it
names, so use unbound tokens only when the caller is your own trusted backend
that takes the tenant from its authenticated session. Audit records carry the
context and context_source (authenticated or request) either way.
Observability
haltsql_auth_total{method,result}:methodistoken,oidc,mtls,anonymousornone;resultisok,rejectedorunavailable.haltsql_tls_reload_total{result}.- OIDC rejections are logged at
infowith a stable reason code (expired,bad_signature,wrong_audience,groups_overage,no_group_policy, ...) and the peer address. JWKS refresh failures are logged atwarn.
Threat notes
- Algorithm confusion (
alg: none, HS256 signed with the RSA public key) is impossible: HMAC andnoneare never accepted and the key type must match the algorithm. - Tenant spoofing: an OIDC or mTLS caller cannot change its tenant via the
request body (
CONTEXT_NOT_ALLOWED). - Issuer confusion: a token is only verified by providers whose
issuerequals itsiss, and then must pass that provider's checks. - IdP outage: bounded stale keys, then fail closed.
- Fail closed: every verification error is a denial; clients must treat any non-200 response as a denial.
- Group claims: a token whose groups overflowed (Entra ID overage), whose
groups claim is missing or malformed, or whose groups match no rule
without a
default, is rejected. Group membership is read only from the signed token, never from the request.
Implementation notes
Signatures, hashes, HMAC, PBKDF2 and base64 come from maintained OCaml
libraries (mirage-crypto, digestif, kdf, base64). The compact-JWS
decoder, JWK parsing and the claim checks in lib/auth are HaltSQL's own
code on top of those primitives.
Why JWT verification is in-tree
Replacing lib/auth/jwt.ml and jwk.ml with the jose opam package was
evaluated against jose 0.11.0 and not adopted. In that version:
- RS384, RS512 and PS256 are not verifiable (three of the seven algorithms HaltSQL accepts), and there is no RSA-PSS support.
Jws.validatechooses the verification routine from the key type and does not compare it with the headeralg, so the algorithm is not bound to the key.Jwk.of_pub_jsonandJwks.of_jsonacceptkty: octkeys from a JWKS, which then verify by HMAC-SHA256. HaltSQL never accepts symmetric keys.- A non-JSON header, an unknown
enc, a JWKS that is not an object and a short ECDSA signature raise exceptions instead of returning errors. - Padded and non-canonical base64url,
critheaders, sub-2048-bit RSA keys and JWKs carrying private members are accepted.
Keeping the checks listed under Threat notes would have
required HaltSQL's decoder, key checks and three algorithms to stay, with
the library wrapping the same mirage-crypto calls for the other four. The
oidc package depends on jose and was not adopted for the same reason.
Because the code stays in-tree, it is tested the way a library would be:
- Known-answer vectors (
test/auth/test_jose_vectors.ml). The RS256 and ES256 examples of RFC 7515 appendix A, the Ed25519 example of RFC 8037 appendix A and the RS256 example of RFC 7520 section 4 must verify; the HS256, ES512, PS384 and unsecured examples of the same documents must be rejected. The examples are extracted from the RFC texts by a script, not retyped. A second set holds one token for each of the seven accepted algorithms, signed by pyca/cryptography. Both sets were verified with pyca/cryptography before the tests relied on them (test/auth/vectors/interop_vectors.py check). - Negative tables (
test/auth/test_jose_negative.ml, about 700 rows in the style of Project Wycheproof). Each row is one malformed signature, key, header or claim with the error it must produce; an exception is a failure. ECDSA:rorsof 0 or at least the group order, DER instead of the fixed-widthr || s, wrong lengths, a curve that does not match the algorithm. RSA: signatures shorter or longer than the modulus or not below it, PKCS #1 v1.5 presented as PSS and the reverse, the wrong hash, wrong PSS salt length or MGF hash, hand-built padding errors. EdDSA:Sat or above the group order, public keys of small order. Keys:oct, private members,use,key_ops, a contradictingalg, duplicatekids, duplicate members, even or tiny RSA exponents, RSA moduli outside 2048 to 8192 bits. Headers:alg: nonein any letter case,crit,"b64": false, and tokens that carry their own key (jwk,jku,x5u,x5c). Claims:exp,nbfandiatas strings, fractions, negative, non-finite and oversized numbers;audas a string and as an array; the exact leeway boundaries. - Fuzzing (
test/fuzz/fuzz_jwt.ml, on everydune test). Valid tokens for all seven algorithms and their JWKS are mutated. The decoder, the JWKS parser and verification must never raise, and a token is accepted only if it is byte for byte one of the valid tokens.
Three behaviours are deliberate and pinned by those tests:
- An ECDSA signature is accepted with either of its two
svalues. JWS has no low-srule and identity providers emit both. haltsql-server never identifies a token by its signature bytes. - When a JWKS has several keys with the same
kid, the first one that fits the token's algorithm, in document order, is the only one tried. typis not interpreted. A token is told apart from other JWTs of the same issuer byaudandrequired_claims.
Verifying tokens at the edge instead
An operator who wants no JWT handling inside haltsql-server can leave
server.auth.oidc empty and validate tokens in front of it: Envoy's
jwt_authn filter, oauth2-proxy, or a service mesh's request authentication.
haltsql-server then authenticates the edge itself, not the end user: set
client_auth: required and give the proxy's client certificate (or its
SPIFFE ID) an entry under server.auth.mtls.identities, which pins the
policy and the context as described under mTLS identities.
haltsql-server does not read an identity, a policy or a tenant from request
headers the edge adds (a forwarded JWT payload, X-Forwarded-User and
similar); nothing of the validated token reaches the verdict. Every request
through one edge identity therefore gets that identity's one policy and
static context, plus the body fields named in its
request_context_fields, which the edge must then be trusted to set
correctly. When the policy or the tenant has to come from the token, either
run one edge identity per tenant or use the built-in OIDC verification.