Authentication & TLS
Enable API-key authentication and serve gRPC over TLS.
Sepp has two independent authentication planes: API keys gate the gRPC job API and a separate set of keys gates the admin UI. You can enable either without the other.
API key authentication
The gRPC API (enqueue, reserve, ack and everything else clients call) is gated by [auth] api_keys, a list of named bearer secrets. It is off by default: a fresh server accepts any client that can reach the port.
[auth]
api_keys = [
{ name = "worker-pool", key = "a-long-random-secret" },
{ name = "producers", key = "another-long-random-secret" },
]The name labels the client (a worker pool, a producer service); only the key is secret. Clients present just the key, names never cross the wire.
Each first-party client takes the key as a connect option and sends it as an authorization: Bearer <key> header on every request:
use sepp_rs::client::SeppClient;
let client = SeppClient::builder("https://sepp.example.com:50051")
.api_key("a-long-random-secret")
.tls() // requires the `tls` feature
.connect()
.await?;from sepp import SeppClient
client = await SeppClient.connect(
"sepp.example.com:50051",
api_key="a-long-random-secret",
tls=True,
)import { SeppClient } from "sepp";
const client = await SeppClient.connect("https://sepp.example.com:50051", {
apiKey: "a-long-random-secret",
});A request with no key or an unrecognized one is rejected with UNAUTHENTICATED. The keys are opaque, so generate long random values and treat them like passwords. They are unrelated to the admin UI keys covered below.
Keys can also be added and revoked from the admin UI's Config page (admin role). The key is generated in the browser, or paste your own to register an existing secret. Either way it is shown once at creation; the server stores it in sepp.toml but only ever reports names back. The same page can turn API-key auth off entirely, which deletes the whole list; the UI asks for confirmation since the port then accepts any client.
An open gRPC port accepts any client
With api_keys unset, anyone who can reach the port can enqueue, reserve and delete jobs. Set keys before exposing sepp beyond a trusted network and pair them with TLS so the key is not sent in the clear.
Key allow-list semantics
api_keys is a tri-state:
api_keys | Effect |
|---|---|
| omitted | Authentication off; every request is allowed. |
| a non-empty list | Only the listed keys are accepted. |
[] (empty list) | Every request is rejected. |
The empty list is a deliberate "deny all" you can use to seal the gRPC plane. Revoking the last key through the admin UI leaves this state rather than turning auth off; turning auth off is its own explicit action there. Every listed key is equivalent; list more than one so you can rotate without a gap (see Rotating keys).
Enabling TLS
Serve the gRPC API over TLS by pointing server.tls_cert_path and server.tls_key_path at a PEM certificate and private key. Set both or neither; one without the other is rejected at load. Both are restart-only.
[server]
tls_cert_path = "/etc/sepp/tls/server.crt"
tls_key_path = "/etc/sepp/tls/server.key"Clients then connect over TLS and must trust the certificate's CA, so a self-signed certificate means distributing it to every client. This is the gRPC listener's TLS only; the admin UI has its own, independent settings.
Keys over plaintext warning
API keys are bearer tokens: the Bearer header is sent on every request and grants access to whoever holds it. Without TLS that secret crosses the network in cleartext, where anyone on the path can read and replay it. Always enable TLS alongside keys on any network you do not fully trust, or terminate TLS in a proxy in front of sepp.
Rotating keys with hot reload
auth.api_keys is hot-reloadable. Editing the list takes effect on the next request, with no restart and no dropped connections. To rotate without a gap:
- Add the new key alongside the old one, in the file or from the admin UI.
- Move clients over to the new key.
- Remove the old key.
Removing a key takes effect immediately, so anything still presenting it starts getting UNAUTHENTICATED.
Admin UI authentication
The admin UI authenticates against its own [admin] keys, a list of named keys that each carry a role. These are separate from the gRPC api_keys.
[admin]
listen_addr = "0.0.0.0:9465"
keys = [
{ name = "ops", key = "a-long-random-secret", role = "admin" },
{ name = "ci", key = "another-secret", role = "operator" },
]- Omitting
keysturns admin auth off, which is only allowed when the listener is bound to a loopback address. A non-loopback bind with no keys is rejected at startup. The bound address is fixed at boot, so deleting the keys from a live, non-loopback server fails closed (requests return "restart required") rather than silently opening the UI to the network. - Browser login takes a name and key, which must match the same entry. On success the server sets an
HttpOnly,SameSite=Strictsession cookie that lastssession_ttl_ms(default 12 hours). - Scripts can skip the login and send the key directly as an
Authorization: Bearer <key>header on each request.
Serve the UI over HTTPS with admin.tls_cert_path and admin.tls_key_path (restart-only, independent of the gRPC certificates and free to point at the same files), or terminate TLS in a fronting proxy or SSH tunnel. The session cookie is marked Secure only when this listener terminates TLS itself; behind a proxy the final hop is plain HTTP, so the flag stays off. See Deployment for the proxy setup.
Secrets are never exposed through the UI: the config endpoint reports auth.api_keys as names only and admin.keys as names and roles only. Worker API keys can be added and revoked through the UI but never read back; admin.keys cannot be edited through the admin API at all; change them in sepp.toml on the server.
Admin roles
Roles are strictly ordered: viewer < operator < admin. A key's role defaults to admin when omitted.
| Role | Allowed actions |
|---|---|
viewer | Read-only: dashboards, queue and job inspection, the redacted config and the live SSE stream. |
operator | Everything a viewer can do, plus job-level mutations: enqueue, dead-letter, requeue and delete dead letters. |
admin | Everything an operator can do, plus config-level changes (create, update and delete queues, edit the config) and reading the audit log. |
A request whose role is too low gets 403 Forbidden; an unauthenticated one gets 401 Unauthorized. Roles are read live from the config on every request, so changing a key's role applies immediately to existing sessions, with no re-login. Rotating a key's secret signs out any session that was established with the old value.
Audit log
Every successful admin mutation and login is recorded twice: as a line on the sepp::audit tracing target for your log aggregator, and as a durable entry in the database. The stored log is shown on the UI's Audit page with a live tail and is kept forever.
Reading the audit log requires the admin role, in the UI, over the API and on the live stream. Lower roles neither see the Audit page nor receive audit events.
An entry carries a sequence number, a timestamp, the actor (the key's name, or local when admin auth is off on a loopback bind), its role, the action and a details object. Actions that touch jobs name them: job.enqueue records the job ID and jobs.dead_letter, dead_letters.requeue and dead_letters.delete record the IDs of every job actually affected. Those three also take an optional reason in their confirmation dialogs, recorded in the entry. The full set of action labels: queue.put, queue.delete, job.enqueue, jobs.dead_letter, dead_letters.requeue, dead_letters.delete, config.put, auth_key.add, auth_key.revoke, auth.disable, session.login and session.logout.
Only mutations and logins are audited, not reads. Key secrets never appear in an entry. Failed logins are the one exception to the dual recording: they get a session.login_failed tracing line but are never stored, so an unauthenticated caller cannot bloat the database.
Scripts can page through the trail with an admin bearer key:
curl -H "Authorization: Bearer <admin-key>" \
"http://localhost:9465/admin/api/v1/audit?limit=100&action_prefix=dead_letters."Entries come newest-first. actor filters by exact name, action_prefix by label prefix. Follow next_before (pass it back as before) until it is null, which marks the oldest entry; a short page with next_before set means the server capped that scan, not that the trail ended.
Storage changes nothing on the tracing side: sepp::audit remains a dedicated target you can route to its own sink; see Observability.