Hardening guide
This page covers the ZTXBAS-specific settings that matter for a production install. General Linux hardening (kernel sysctls, SSH, fail2ban, host firewall baseline) is out of scope — the CIS Benchmarks and the Ubuntu Server hardening guide cover that ground better than we can.
If you are still kicking the tires, use the docker run smoke test in
Getting started first — this page assumes you're
about to put ZTXBAS somewhere real users can reach.
1. Threat model
ZTXBAS is designed to resist:
- Phishing. Origin binding + the display on the phone means an approved auth cannot be replayed against a different origin.
- Replay on the wire. All API requests are HMAC-SHA256 signed with a bounded-skew timestamp and a nonce. TLS provides the outer channel.
- RP impersonation. Every application has its own HMAC secret; a leaked secret only compromises that one application.
- Stolen approval tokens. JWTs are short-lived, ES256-signed,
bound to
sub,aud(application id), andorigin.
ZTXBAS does not protect against:
- A compromised admin host — anyone with a session cookie or the admin password can create applications and rotate secrets. Treat console access like root.
- A malicious RP — a relying party that lies about its origin can only phish its own users, but it can do that.
- An unlocked, enrolled phone in an attacker's hand. Device-level attestation defends against tampering, not against a legitimate user handing over an unlocked device.
- Anything happening below the container (host kernel, hypervisor, physical access to the disk holding the SQLite file).
2. Verify the image before you run it
Every release is signed with cosign. Verify before pulling to production:
# Get the public key once (pin both URLs; either is fine).
curl -sO https://corezt.com/ztxbas/cosign.pub
# Verify the tag you're about to run.
cosign verify --key cosign.pub ghcr.io/corezt/ztxbas:1.0.0
Then pin to the digest, not the tag, in your deployment manifests:
docker pull ghcr.io/corezt/ztxbas:1.0.0
docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/corezt/ztxbas:1.0.0
# ghcr.io/corezt/ztxbas@sha256:...
Digests are immutable; tags are not. If our infrastructure is ever compromised, a tag can be repointed. A digest cannot.
Key rotation. If the signing key is ever rotated, we publish the
new key alongside the old one for one full release cycle so integrators
have time to migrate. Announcements go on the release page and in
SECURITY.md. Keyless-with-OIDC signing is on the roadmap once
GitHub Actions is enabled; until then, cosign runs from the release
manager's workstation.
3. Container runtime settings
The stock docker-compose.yml ships with the settings below. If you
copy them into a different orchestrator, keep them intact.
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp:size=16m
user: "10001:10001" # non-root in the image
Why each one:
cap_drop: ALL— ZTXBAS is a userspace HTTP + UDP server. It needs no capabilities. If a bind fails, the port is privileged (< 1024); use the mapped 8443/8080/9443 defaults or bind on the host with a reverse proxy rather than grantingCAP_NET_BIND_SERVICE.no-new-privileges— prevents setuid binaries in the image from escalating. There aren't any, but future dependencies could add one.- **
read_only: true+ tmpfs /tmp** — the container writes only to/var/lib/ztxbas(the data volume) and/tmp` (transient). A read-only root filesystem defeats a large class of persistence techniques cheaply. - Non-root user — the image runs as uid/gid 10001. The data
directory is created 0700 on first boot; do not
chown -Rit to root "to fix a permissions error." That error means the volume was created by a different user and needs to be fixed at the host level.
Resource limits are worth adding on top of the security posture:
deploy:
resources:
limits:
memory: 512M
pids: 200
A single instance handles hundreds of concurrent challenges well under that ceiling; the limit exists to contain runaway conditions, not to size the workload.
4. Network exposure
ZTXBAS listens on three ports. They have very different exposure profiles.
| Port | Protocol | What it carries | Public exposure |
|---|---|---|---|
| 8443/tcp | HTTPS | RP-facing HMAC-authed API | Public, TLS-terminated |
| 8080/tcp | HTTP(S) | Admin console | Never on the open internet |
| 9443/udp | AES-GCM | Mobile app transport | Public, direct-published |
The mobile UDP port carries its own crypto (AES-256-GCM envelope +
device-bound ECDSA attestation — see SECURITY.md); it does not sit
behind a reverse proxy because standard HTTP proxies cannot forward
this framing. Publish it directly on the host and open UDP/9443 in
the firewall.
Minimal ufw recipe on the host:
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp comment 'ssh'
ufw allow 443/tcp comment 'caddy → ZTXBAS 8443'
ufw allow 9443/udp comment 'ZTXBAS mobile transport'
# 8080 is NOT opened — see section 6.
ufw enable
5. TLS
ZTXBAS has three supported TLS shapes. Pick one.
Recommended: TLS-terminating reverse proxy (Caddy). Caddy handles
ACME automatically, so certificate renewal is invisible. The stock
docker-compose.caddy.yml overlay wires this up. ZTXBAS serves plain
HTTP on the loopback side; Caddy handles TLS on the public side.
Direct-bind TLS. Set ZTXBAS_TLS_CERT and ZTXBAS_TLS_KEY to
mounted PEM paths. ZTXBAS reads them once at startup — there is no
built-in ACME and no hot reload. A cert-renewer (external certbot,
Let's Encrypt agent, whatever) that writes fresh files must be paired
with a container restart when the files change. In practice this
means a nightly docker restart ZTXBAS on the renewal cadence; the
restart is a few seconds of unavailability.
TLS-terminating cloud load balancer. Same shape as Caddy but the
LB owns the cert. Make sure the LB adds X-Forwarded-For / -Proto
headers and read section 7 on trusted proxies.
TLS 1.2+ only. TLS 1.3 preferred. If you terminate at Caddy, both are the default. If you terminate at a load balancer, disable everything below TLS 1.2 explicitly.
6. Exposing the admin console safely
The admin console at port 8080 is the single most sensitive surface on your ZTXBAS instance. Three supported paths, in decreasing order of safety:
A. Loopback + SSH tunnel (safest). Leave ZTXBAS_CONSOLE_LISTEN_ADDR
at its default (127.0.0.1:8080). Reach it from your laptop with:
ssh -L 8080:127.0.0.1:8080 ztxbas.example.com
# then open http://127.0.0.1:8080 in your browser
Nothing changes at the ZTXBAS config. The console is unreachable from the internet by design. This is the default and the recommendation.
B. Reverse proxy with basic-auth + IP allowlist. Expose the
console under a hostname like console.ztxbas.example.com behind
Caddy, with basic auth in front of it and an IP allowlist for your
office / VPN range. See deploy/docker/Caddyfile.example. This is
appropriate for teams; do not use basic-auth alone as your only
guard.
C. mTLS at the reverse proxy. For high-value deployments, front the console with a Caddy or nginx that requires a client certificate. Same allowlist story on top.
The console fail-safe. ZTXBAS refuses to start if
ZTXBAS_CONSOLE_LISTEN_ADDR is bound to a non-loopback address
without TLS. Override with ZTXBAS_ALLOW_INSECURE_CONSOLE=1 only
when:
- The bind address is loopback but Docker's port publishing maps it to a private / loopback-only host address (this is the case in the stock recipes), or
- You are running behind a reverse proxy that itself terminates TLS
and you have verified the proxy strips inbound
Authorization/ cookie headers from unauthenticated paths.
Never set ZTXBAS_ALLOW_INSECURE_CONSOLE=1 on a container whose
console port is published to 0.0.0.0 without a proxy in front.
7. Reverse proxy hygiene
If ZTXBAS sits behind Caddy / nginx / a load balancer, tell it who to trust for client IPs:
ZTXBAS_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
Effects:
- Rate limits (per-IP on admin login attempts) work against the real client, not the proxy.
- Audit-log source IPs reflect the real caller.
- Without this, ZTXBAS ignores
X-Forwarded-For— a spoofed header from an untrusted peer would let an attacker forge client IPs.
At the proxy, strip any inbound X-Forwarded-* headers before
setting your own. Otherwise an internet client can lie about their
X-Forwarded-For and the proxy will pass it through. In Caddy:
reverse_proxy ztxbas:8443 {
header_up -X-Forwarded-For
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
8. SMTP
ZTXBAS refuses to start without SMTP configured — enrollment emails are how users receive their pairing QR, and silent failure here was a documented failure mode of the old C server.
Required at startup:
ZTXBAS_SMTP_HOST,ZTXBAS_SMTP_PORTZTXBAS_SMTP_USER,ZTXBAS_SMTP_PASSZTXBAS_SMTP_FROM
Recommendations:
- Use a dedicated sending domain (
auth.example.com,noreply-ztxbas.example.com) with SPF, DKIM, and DMARC configured on it. Enrollment emails to first-time users are exactly the kind of traffic mail providers filter aggressively. - Prefer a transactional relay (Postmark, SES, Mailgun, an internal Postfix that already has DKIM configured) over a shared consumer provider.
- Test end-to-end after any change: from the console, resend an enrollment email to a throwaway address you can watch land in the inbox, spam folder, or a mail log.
- SMTP credentials should be delivered as Docker/Kubernetes secrets, not plain environment variables in a compose file that ends up in git.
9. Secrets on disk
ZTXBAS writes three sensitive files inside the data volume:
| File | Purpose | Permissions |
|---|---|---|
ztxbas.db (+ -wal, -shm) | SQLite: apps, users, origins, audit | 0600 |
jwt-signing.key | ES256 private key for JWTs | 0600 |
breakglass.txt (first boot only) | One-time admin password | 0600 → gone |
Rules:
- Do not chmod these looser to work around a permissions issue. If the container can't read its own data dir, the volume was created by the wrong user (see section 3).
- Back up the whole data directory as a unit. The JWT signing key and the SQLite state must stay in sync — restoring one without the other means every existing JWT is either unverifiable or trivially forgeable.
- The breakglass password is written on first boot only, single-use, expires on first successful admin login. Rotate immediately after logging in; do not treat it as a durable password.
Per-application HMAC secrets (given to RPs) are shown once at application-create time and never displayed again. RPs should store them in their own secrets manager and never commit them to source control. Rotate with:
docker exec ztxbas ztxbas app rotate <app-id>
Currently a rotation is atomic — RPs experience a brief window where they must roll out the new secret before the old one stops working. Dual-secret rotation with a configurable grace window is on the v1.1 roadmap.
10. Persistence and backups
SQLite in WAL mode holds everything: applications, origins, users, audit log, admin account, session store. Backup is a filesystem operation.
Two supported patterns:
Online backup with sqlite3 .backup. Runs against the live DB
without stopping the container:
docker exec ztxbas sqlite3 /var/lib/ztxbas/ztxbas.db \
".backup /var/lib/ztxbas/backup.db"
docker cp ztxbas:/var/lib/ztxbas/backup.db ./ztxbas-$(date +%F).db
# Also grab the JWT signing key — see section 9.
docker cp ztxbas:/var/lib/ztxbas/jwt-signing.key ./jwt-$(date +%F).key
Encrypt at rest (age, gpg, whatever your backup tool provides). The JWT key is a P-256 private key; treat it like an SSH host key.
Offline snapshot. Stop the container, tar the volume, restart:
docker stop ztxbas
docker run --rm -v ztxbas-data:/data -v $PWD:/backup alpine \
tar czf /backup/ztxbas-$(date +%F).tgz -C /data .
docker start ztxbas
Simpler; incurs a few seconds of downtime per backup.
Test restore, quarterly at minimum. An untested backup is a statement of intent, not a recovery capability. Stand up the tarball in a scratch container and confirm the console loads, an application lists, and a fresh JWT verifies against the restored key.
11. Logging and monitoring
ZTXBAS writes structured JSON to stdout — journald, docker logs,
Loki, and every SIEM shipper reads it natively.
Sample line:
{"time":"2026-01-15T10:04:11.812Z","level":"WARN","msg":"hmac.verify.fail",
"app_id":"app_a1b2c3d4e5","reason":"timestamp_skew","skew_sec":312}
Fields worth alerting on:
hmac.verify.failat high rate for a singleapp_id→ probable clock skew or leaked-and-rotated secret.admin.login.failat high rate from a single client IP → brute force attempt against the console.challenge.expiredat high rate → mobile push delivery is degraded, or the phone(s) can't reach 9443/udp.smtp.send.failat any rate → users are silently not getting enrollment emails.
Health probe at GET /health returns liveness and version. It
deliberately does not check downstream (SMTP, ztxlib) — a health
probe that fails during a partial outage would take healthy instances
out of rotation. Use structured-log alerts for downstream failures.
12. Rate limiting
Defaults are sensible for a single-instance deployment:
- Public endpoints: per-application, with a burst allowance. An application creating a hundred challenges a second is either misusing the API or under attack; both cases benefit from throttling.
- Admin endpoints: per-client-IP, tighter. Login attempts are aggressively throttled per admin username too.
You can raise the public-endpoint limit for high-volume RPs; you
probably shouldn't lower the admin limit. Both are set via env vars
documented in deploy/README.md.
If you're fronting ZTXBAS with a WAF (Cloudflare, AWS WAF), that becomes an additional shaping layer — ZTXBAS's built-in limits are the inner defense, not the primary one.
13. Upgrades
- Read the release notes. Migrations are append-only, so upgrades are safe; downgrade is not supported because a newer migration can add columns the older binary doesn't know about.
- Cosign-verify the new tag against the same
cosign.pubyou already trust. docker pullthe new digest, back up the data volume (section 10).docker compose up -d— the new container picks up the volume and runs any new migrations at startup.- Watch the logs for a clean startup and a fresh
/healthresponse with the newversionfield.
Because state lives in a mounted volume and the container is stateless, rollback is: shut down, restore the pre-upgrade backup, run the previous image tag.
14. Incident response
Leaked application HMAC secret.
docker exec ztxbas ztxbas app rotate <app-id>
Ship the new secret to the RP's config. In-flight challenges created under the old secret remain valid (they're bound to challenge ID and origin, not to the RP's HMAC secret), but any new API call from the RP with the old secret fails immediately.
Leaked JWT signing key. The key lives in jwt-signing.key inside
the data volume. Rotation is manual and disruptive in v1:
docker stop ztxbas
docker exec -u root ztxbas rm /var/lib/ztxbas/jwt-signing.key # or delete via the host mount
docker start ztxbas
A fresh key is generated at startup and published at
/.well-known/jwks.json under a new kid. Every JWT issued under
the old key is now unverifiable — the JWKS endpoint only publishes
the currently loaded key, so RPs that fetched a JWT in the last
five minutes (the JWT lifetime) will see verification failures during
the rollover window. Assume every currently-valid session is
compromised and log every user out at the RP side.
Multi-key JWKS rotation with a graceful overlap window is on the v1.1 roadmap.
Leaked breakglass password. It's single-use and expires on first
login. If it leaked before first use, docker exec ztxbas ztxbas admin reset-password --user admin regenerates a fresh one and
revokes every existing session.
Suspected admin session compromise. From the console: Account → Sessions → Revoke all other sessions. Then rotate the admin password. The audit log preserves session IDs and source IPs.
Suspected mobile-device compromise (user reports lost phone).
# From the RP side, using an SDK — or from the console under Users.
curl -X DELETE https://ztxbas.example.com/v1/users/<user-id> ...
The user is deregistered atomically. Their device's keys are useless against ZTXBAS immediately. Re-enroll with a fresh device through your account-recovery flow.
15. Air-gapped deployments
Everything works. There is no phone-home, no license server, no telemetry, no forced updates. The pieces that need adjustment:
- Image distribution. Mirror
ghcr.io/corezt/ztxbasto your internal registry. Verify cosign at ingest, then re-tag internally. - TLS. Auto-ACME won't work; use an internal CA and static PEM
files (
ZTXBAS_TLS_CERT/_KEY, section 5). - SMTP. Point at an internal Postfix / Exchange relay.
- Time. Chrony pointed at your internal NTP source. HMAC timestamp skew is bounded; a wrong clock breaks the RP flow before it breaks anything else, and it does so silently.
- SDK distribution. The Go SDK is fetchable via GOPROXY; the Node and Python SDKs need mirroring or vendoring for RPs behind an air-gap.
16. What ZTXBAS deliberately doesn't do
If you need any of these, ZTXGate is the broader access platform — the same mobile app can be used, while licensed ZTXBAS capability is integrated into ZTXGate rather than deployed as a separate ZTXBAS server:
- SSO / SAML / OIDC for administrators
- Admin RBAC beyond a single account
- SCIM user provisioning
- MDM posture attestation as a factor
- SIEM export beyond stdout log shipping
- Multi-tenant isolation
- Network-wide enforcement (VPN, RDP, SSH, legacy apps)
Attempting to bolt any of these onto ZTXBAS breaks its "one process, one job" shape and inevitably means running an old fork forever. Migrate to ZTXGate instead; the mobile app on your users' phones is already the right one.