Getting started
This page takes you from zero to a verified JWT in about fifteen
minutes on your own machine. It's a local demo, not a production
deployment — one docker run command with the mobile app on the
same LAN, no TLS, no reverse proxy. For production recipes (self-
terminated TLS, compose + Caddy with Let's Encrypt, or Kubernetes),
see deploy/README.md
after you've walked through this.
1. Run ZTXBAS
ZTXBAS ships as a signed container image at ghcr.io/corezt/ztxbas.
The image is signed with cosign; verifying the signature is optional
for a demo but recommended before any real install
(see deploy/README.md).
You'll also need an SMTP relay reachable from the container — the enrollment step in section 3 sends a link by email.
Prep an env file. Copy the shipped example and fill in a couple of values:
cp env.txt .env
Find your machine's LAN IP (the phone must be able to reach it):
ip -4 addr show | grep -oP '(?<=inet\s)\d+\.\d+\.\d+\.\d+' | grep -v '^127\.'
Edit .env and set:
ZTXBAS_PUBLIC_URL=http://<lan-ip>:8443
ZTXBAS_CONSOLE_LISTEN_ADDR=0.0.0.0:8080
ZTXBAS_ALLOW_INSECURE_CONSOLE=1
ZTXBAS_SMTP_HOST=smtp.example.com
ZTXBAS_SMTP_PORT=587
ZTXBAS_SMTP_USER=your-smtp-user
ZTXBAS_SMTP_PASS=your-smtp-pass
ZTXBAS_SMTP_FROM=ztxbas@yourdomain.com
ZTXBAS_PUBLIC_URL is baked into every enrollment link and QR code,
so it has to be an address the phone can actually reach. Loopback
won't work.
ZTXBAS_CONSOLE_LISTEN_ADDR=0.0.0.0:8080 binds the console inside
the container to all interfaces so Docker's port mapping can reach it;
ZTXBAS_ALLOW_INSECURE_CONSOLE=1 tells the startup fail-safe that
this is intentional. The console still isn't publicly exposed —
-p 127.0.0.1:8080:8080 in the docker command restricts it to host
loopback.
Run it:
docker run --rm --name ztxbas \
--env-file .env \
-p <lan-ip>:8443:8443 \
-p 127.0.0.1:8080:8080 \
-p 9443:9443/udp \
-v ztxbas-data:/var/lib/ztxbas \
--cap-drop ALL --security-opt no-new-privileges \
--read-only --tmpfs /tmp:size=16m \
ghcr.io/corezt/ztxbas:latest
Three ports are involved:
| Port | Bound to | Purpose |
|---|---|---|
| 8443/tcp | LAN IP | RP-facing API — your app / SDK calls this. |
| 8080/tcp | Loopback | Admin console . |
| 9443/udp | All ifaces | Mobile-app transport. Publish directly — this |
| is a custom framed protocol; HTTP proxies can't | ||
| forward it. |
The admin console stays on loopback because you open it in a browser
on the same host. If you're SSH'd into a remote host, add
-L 8080:localhost:8080 to your SSH command and open
http://127.0.0.1:8080 locally.
Health check:
curl -s http://<lan-ip>:8443/health
# {"status":"ok","version":"1.0.0"}
2. Log in and create an application
An application is your RP. It gets an id and an HMAC secret that you sign API requests with.
Grab the first-boot admin password from the container logs:
docker logs ztxbas 2>&1 | grep -i breakglass
Open 127.0.0.1:8080, log in with that password, and set a proper one when prompted (the breakglass password is single-use).
From the console: Applications → New application, give it a name, and you'll be shown the app id and HMAC secret once. Save both - the secret isn't recoverable.
Prefer the CLI? The same thing works via docker exec:
docker exec ztxbas ztxbas app create "Quickstart App"
# Application created.
# ID: app_a1b2c3d4e5
# Name: Quickstart App
# HMAC secret: 5f6e7d8c9b0a...
3. Register an origin
An origin is a scheme://host[:port] your app authenticates from.
It's shown to the user on their phone during approval - this is the
anti-phishing anchor.
Easiest path: in the console, open the application you just created and
add the origin (https://app.example.com) with a display name (e.g.
Example App). The display name is what the user sees on their phone.
If you'd rather do it from code, the SDKs handle the HMAC signing for you - signing by hand is fiddly (see HMAC signing for the canonical form).
4. Register a user and challenge
Pick your language. All three run the same flow: register user → create challenge → poll → get a verified JWT back.
Go
c, _ := ztxbas.New("http://127.0.0.1:8443", "app_a1b2c3d4e5", "5f6e7d8c9b0a...")
_, _ = c.RegisterUser(ctx, ztxbas.RegisterUserRequest{Email: "alice@example.com"})
ch, _ := c.CreateChallenge(ctx, ztxbas.CreateChallengeRequest{
UserEmail: "alice@example.com",
Origin: "https://app.example.com",
})
claims, _ := c.PollChallenge(ctx, ch.ChallengeID)
fmt.Println(claims.Email, claims.Origin)
Node/TypeScript
const c = new Client("http://127.0.0.1:8443", "app_a1b2c3d4e5", "5f6e7d8c9b0a...");
await c.registerUser({ email: "alice@example.com" });
const ch = await c.createChallenge({
user_email: "alice@example.com",
origin: "https://app.example.com",
});
const claims = await c.pollChallenge(ch.challenge_id);
console.log(claims.email, claims.origin);
Python
c = Client("http://127.0.0.1:8443", "app_a1b2c3d4e5", "5f6e7d8c9b0a...")
c.register_user("alice@example.com")
ch = c.create_challenge("alice@example.com", "https://app.example.com")
claims = c.poll_challenge(ch["challenge_id"])
print(claims.email, claims.origin)
For a real deployment, swap http://127.0.0.1:8443 for your TLS
front-end (https://ztxbas.example.com).
5. What just happened
- The user got an enrollment email; they installed the ZTXBAS authenticator app and paired their phone. The app talks to ZTXBAS server over the UDP port you exposed on 9443.
- Your call to
create_challengepushed a biometric prompt to their device showing "Example App" (the origin's display name). - They approved with fingerprint / face.
- ZTXBAS minted an ES256 JWT bound to
alice@example.comandhttps://app.example.com. - Your SDK fetched the JWKS from
/.well-known/jwks.json, verified the signature, and returned the claims.
Ready-to-run quickstarts
Full end-to-end demos in each language:
Each is about 30 lines and exercises the same flow you see above.
Next steps
- Understand what makes the origin binding safe: Origin binding.
- Ship to prod (compose + Caddy, Kubernetes, or systemd):
deploy/README.md. - Harden the install: Hardening guide.
- Pick a stack: Go SDK, Node SDK, Python SDK.