Comments: secured by ByteBind

There is a comment box at the bottom of this post. It runs on Isso, which is small, self-hosted, and refreshingly uninterested in becoming a social network.

Isso's admin login uses one password. I didn't want another shared password, and I already had ByteBind sitting here looking for a real job. So I put a small gateway in front of Isso.

Reading and posting comments goes straight through. /comments/admin/ requires a ByteBind lease with the comments:moderate authorization. The gateway knows the Isso password and logs in upstream after the lease is granted. Moderators never see that password.

Keeping Isso boring

I left Isso unmodified. It listens on 127.0.0.1:8080, with no public route to the port. Nginx sends /comments/ to the gateway on another loopback port.

reader or commenter
        |
        v
  public gateway  -------->  Isso on localhost
        |
        +-- admin/moderation --> ByteBind lease required

The gateway checks the admin page and the API calls made by its JavaScript. It also checks Isso's signed approve, delete, and edit URLs. Those links would otherwise let anyone holding one moderate a comment without visiting the admin login.

If the ByteBind Authority is down, existing comments and new submissions still work. Moderation waits until it comes back. For emergencies, I can reach Isso's original login through an SSH tunnel to localhost.

This setup should survive an Isso upgrade without carrying a local authentication patch forever. That was the main reason to use a gateway.

No email, and a groundskeeper

The first version required an email address and offered reply notifications. I removed both before inviting anyone to use it. "Optional" was not good enough; if the blog has no use for an address, the form should not ask for one.

The form now asks for a name and comment. The gateway strips email and notification from direct API submissions as well, and the installer clears those fields from comments already in the database. Isso has no SMTP configuration or notification backend.

New comments stay hidden until approved. I can open the ByteBind-protected admin page, but most moderation now happens in Telegram.

The Telegram bot is called Comment Groundskeeper. It polls Telegram rather than accepting a public webhook, and it talks to Isso over localhost. When a comment enters the queue, the bot sends me its author, text, and article link with two buttons: Approve and Delete. /pending shows anything still waiting.

The bot accepts commands and button presses only from numeric Telegram user IDs in its allowlist. Before acting, it fetches the current queue again and gets a fresh signed moderation key from Isso's admin page. A stale button cannot approve a comment that has already left the queue.

This gives me two moderation paths. ByteBind protects the full web interface from tagged devices on my tailnet. Telegram handles the common case from my phone without exposing another HTTP endpoint on the server. Isso's password stays in a root-owned file and is used only for localhost admin sessions.

Admin means admin

My first ByteBind policy required tag:admin and tag:station. It worked on the MacBook. It also excluded my iPhone because a phone isn't a workstation, which is a perfectly accurate label and an unhelpful moderation rule.

The policy now requires tag:admin. Both devices carry that tag and can open the admin page while connected to Tailscale. The lease lasts three minutes and renews while the page remains open.

ByteBind's job here is limited to the moderation door. Isso still stores comments, renders threads, applies its posting limits, and holds new submissions for approval. For the protocol details, see the original ByteBind post and its technical follow-up.

Now I get to find out whether adding a comment section in 2026 was a good idea.

The ByteBind protocol: transcripts and trust boundaries

ByteBind ties public management access to a fresh exchange through an authorized private-network device. The first post covers the motivation. Here I am following the requests and checks required by draft 0.7.

The protocol involves a browser, the public application (the relying party, or RP), and a private Authority. The Authority owns the transaction state and uses an overlay-specific provider to identify and authorize the connecting device.

The paths below are illustrative for RP-defined endpoints. The reference implementation uses /v1/begin and /v1/redeem for control requests and /attest for browser attestation.

1. Create a transaction

The browser sends a POST to the application's authentication endpoint. The RP checks that the request's origin matches its public origin, then asks the Authority to create a transaction over an authenticated private control channel.

The Authority binds the transaction to the registered RP, its allowed browser origin, audience, policy, and profile. It generates:

Value Length Purpose
cid 16 bytes Transaction identifier
C 32 bytes Challenge secret passed to the browser
S 32 bytes Secret released through successful private attestation

The RP returns the browser's portion:

{
  "cid": "<base64url>",
  "C": "<base64url>",
  "authority": "https://authority.example.ts.net:8443"
}

It can also set a secure, HttpOnly state cookie and store its hash against the attempt. The reference implementation uses this to check that the later proof belongs to the initiating browser attempt.

The Authority stores C and S, the transaction bindings, a deadline, and an initial pending status. The RP maintains its own attempt records; it reaches the Authority's state through the control API.

For RPs on separate hosts, draft 0.7 uses HTTPS over the overlay, with the Authority checking the calling node's registered identity. On the same host it uses a Unix socket and kernel-reported peer credentials. In both cases, RP identity comes from the connection's authentication, rather than a caller-supplied identifier alone.

2. Attest over the private path

The browser generates a 32-byte nonce, N, and computes the session profile's initial proof:

H1 = HMAC-SHA256(
    C,
    ASCII("bytebind/v1/h1") || cid || N
)

It sends:

POST /attest
Content-Type: application/json
Origin: https://app.example.com

{"cid":"<base64url>","N":"<base64url>","H1":"<base64url>"}

The Authority validates the expected private host and registered origin before accessing transaction state. It then checks the content type, transaction status and deadline, and the origin stored for this particular cid. It verifies H1 against its stored C, identifies the socket peer through the overlay provider, and applies policy.

When several RPs share an Authority, each transaction retains its expected origin. A page on RP A's origin cannot attest a challenge issued for RP B, even though both origins are registered.

Origin validation addresses malicious browser pages. Non-browser software can choose its own Origin header, so the Authority still checks device identity and authorization. Explicit CORS origins and a JSON request body cause ordinary cross-origin browser requests to be preflighted.

The provider obtains the peer address from the accepted socket and maps it to authenticated overlay identity. A proxy that replaces every client's source address with its own can destroy that mapping. The deployment must preserve the identity of the device being attested.

3. Return the attestation secret

After authorization, the Authority derives a key and encrypts the observed address together with S:

K = HKDF-SHA256(
    ikm = C,
    salt = H1,
    info = ASCII("bytebind/v1/h2"),
    length = 32
)

P   = IP || S
AAD = ASCII("bytebind/v1/h2") || cid || N || H1
H2  = IV || AES-256-GCM(K, IV, P, AAD)

IP is a 16-byte address, using IPv4-mapped IPv6 where necessary. IV is a fresh 12-byte nonce. The encrypted result includes its 16-byte authentication tag, making decoded H2 76 bytes.

The Authority returns {"H2":"<base64url>"}. The browser derives K, authenticates the response against the same associated data, and extracts IP and S.

The associated data binds the private response to the initial proof and transaction. Encryption protects S from an intermediary that lacks C. Anyone with C and the corresponding transcript can derive the key, so the trust model includes protection of the challenge and browser code.

All transcript inputs are decoded bytes. JSON uses unpadded canonical base64url. Parsers reject malformed encodings, incorrect lengths, and unexpected protocol fields. The labels separate each cryptographic use. The document is draft 0.7; those labels identify protocol v1.

4. Redeem through the application

The browser computes:

R = HMAC-SHA256(
    S,
    ASCII("bytebind/v1/redeem") || cid || C || IP
)

It posts cid and R to the RP. The RP checks its own origin and attempt binding, then submits the proof to the Authority's redemption endpoint over the private control channel.

The Authority checks the authenticated RP, audience, attested status, redemption deadline, and expected MAC. It uses its stored peer address when reconstructing the transcript. Successful redemption consumes the transaction and returns a scoped grant.

The RP then issues a management lease or executes the stored request. Its own resource authorization, CSRF protection, session handling, and operation recovery still apply.

The proofs are symmetric MACs, and the private reply uses authenticated encryption. The grant is an authorization response on the control channel. ByteBind does not produce a publicly verifiable signed attestation.

Make the state transitions atomic

The Authority progresses through:

pending → attested → redeemed
   │          │
   └─→ burned ←┘

redeemed and burned are terminal. Deadlines determine whether a transaction is still eligible for a transition.

Attestation performs the identity and proof checks, then makes a conditional database update:

UPDATE challenges
SET status = 'attested',
    attested_ip = :peer_ip,
    attested_peer_id = :peer_id,
    attested_at = :now,
    redeem_expires_at = :redeem_deadline
WHERE cid = :cid
  AND status = 'pending'
  AND challenge_expires_at > :now;

One changed row means success. If two requests race, one of them can transition the record. Redemption uses the same pattern from attested to redeemed.

The provider lookup runs without holding a database write lock; the final update rechecks state and deadline. Failure burns are conditional on the expected state too, so a losing request cannot burn the winner's newer state. Burns must be committed even when the handler returns an error.

The initial host and unregistered-origin failures leave transaction state untouched. Later failures follow the draft's state-specific burn rules.

Bind a grant to one request

The transaction-bound profile adds a request digest:

Q = SHA-256(
    ASCII("bytebind/v1/tx/request")
    || len(method)  || method
    || len(target)  || target
    || len(headers) || headers
    || len(body)    || body
)

Lengths are unsigned 64-bit big-endian byte counts. The method is uppercase ASCII; the target contains the path and query; selected headers are normalized and sorted as specified; the body is the transmitted byte sequence. Length prefixes preserve field boundaries.

The browser computes Q from its request. The RP computes the corresponding digest, sends it when creating the transaction, and stores the request for later execution.

The proof transcripts become:

H1 = HMAC-SHA256(C, ASCII("bytebind/v1/tx/h1") || cid || N || Q)
K  = HKDF-SHA256(C, H1, ASCII("bytebind/v1/tx/h2"), 32)

AAD = ASCII("bytebind/v1/tx/h2") || cid || N || H1

R = HMAC-SHA256(S, ASCII("bytebind/v1/tx/redeem") || cid || C || IP || Q)

H1 carries the request commitment into the key derivation and associated data. The final MAC binds redemption to it as well.

Browser and server must agree on the encoding before framework normalization. Reformatting a JSON body or rewriting a query can change its digest. Headers excluded from the profile's selection remain outside the commitment. After redemption, the RP executes the stored request covered by that digest.

Handle the gap between authorization and execution

Consuming a proof once and executing an operation at most once are separate responsibilities.

If the RP crashes after redemption, the operation may never run. If it runs and the response is lost, the browser may not know the result. Starting another ceremony produces another transaction and can authorize a duplicate business action.

An integration may need a durable operation record and a business-level idempotency key. It may also need an endpoint that reports the result. External side effects depend on the downstream system's idempotency behavior. ByteBind's transcript definitions do not provide these recovery mechanisms.

Enforce the lease on the server

The session profile recommends a 30-second attestation window, a 10-second redemption window beginning at attestation, a 180-second lease, and renewal every 60 seconds.

The Authority returns a relative grant lifetime. The RP sets its deadline no later than:

grant_received_at + min(local_lease_duration, grant.expires_in)

Each host enforces its own deadlines. Relative durations avoid requiring agreement on absolute timestamps; local clock changes and restart behavior still need careful handling.

The browser renews on a fixed schedule without receiving the expiry. The RP checks the lease on protected requests even if the browser still holds the cookie. Leaving the network stops renewals, and the last accepted lease bounds the remaining access. Policy propagation can introduce additional delay before fresh attestations begin failing.

What needs review

The ceremony establishes continuity through possession of C, receipt of S on an authorized private connection, and successful redemption. Those secrets are transferable. The mechanism assumes trusted browser code and an uncompromised authorized device; it does not uniquely identify a browser process or prevent an authorized participant from relaying secrets. A copied session cookie also remains usable until the RP expires it.

The downloadable test vectors cover both profiles and request-encoding boundaries. I reproduced their MACs, derived keys, and encrypted responses against the reference implementation while preparing this article.

Review should also exercise cross-RP origin substitution, concurrent attestation and redemption, durable failure burns, changed request fields, and lost responses around application side effects.

The roadmap snapshot records the remaining deployment tests: real Tailscale identity responses and certificates, cross-node control traffic, macOS/BSD peer credentials, and browser local-network permissions. Independent security review remains outstanding.

The review needs to determine whether these bindings and deployment assumptions connect a public authorization decision to the intended private device. The specification contains the details.

ByteBind: making the private network part of the login

I wanted to protect the management interface of a public application that can control resources on my tailnet. The interface had to stay on its public origin, while management access had to come through an authorized tailnet device.

I called the result ByteBind. A browser contacts the public application and a private Authority during the same authentication exchange. The Authority identifies the device on the private connection, applies policy, and gives the browser proof material to return to the application.

ByteBind isn’t a nicer login screen. It’s no login screen.

No registration ceremony. No OIDC/OAuth ceremony. No redirects, PINs, keys, or account pickers.

For the developer, it’s an import, an app bind, and route binds. For the operator, it’s a few lines of policy. For the user, it’s seamless, ambient authentication.

The developer declares what’s protected. The operator declares who’s trusted. The user just opens the page.

How the exchange works

The public application asks the Authority to create a short-lived transaction. It passes a challenge to the browser, which uses it to contact the Authority over the private network.

The Authority identifies the connecting device through the overlay and applies the application's authorization policy. If the device is allowed, it returns an encrypted response containing a second secret. The browser uses that secret to produce a redemption proof and sends the proof to the public application.

The application redeems it with the Authority. Successful redemption consumes the transaction and returns the authorization result. The application can then issue a management session or perform the requested operation.

The application keeps serving its public interface. The Authority handles private-network identity and policy. A browser has to reach both during the exchange.

What access depends on

The Authority needs an authenticated connection-to-device mapping and a policy allowing that device to manage the application. A private address by itself says too little.

With Tailscale, the provider uses node identity and policy such as required tags or an allowlist. The Authority can return only the claims the application needs, keeping the rest of the device metadata within the private identity service.

The Authority also checks the origin bound to each transaction. Without that check, a website open on an authorized laptop could borrow the laptop's network access to authenticate to another application. The application performs its own origin checks at its authentication endpoints.

Sessions and individual operations

ByteBind has two profiles.

The session profile issues a short management lease. The draft recommends three minutes, renewed every minute through another private-network exchange. Leaving the network stops successful renewals; access ends when the existing lease expires. This gives a bounded delay between losing network access and losing management access.

The transaction-bound profile covers one operation. The browser and application compute a digest of the request and include it in the proof. The application stores the request and executes it after successful redemption. A service restart or credential rotation can use this profile.

The application still owns execution and recovery. If a response is lost after an operation runs, obtaining fresh authorization must not accidentally repeat the operation.

What the device proves

The exchange establishes participation through an authorized private-network device. It cannot identify the person holding that device. An application that needs human identity or approval has to add that step.

An authorized device is a trust assumption. Compromising it, stealing the ceremony's secrets, or copying a valid session cookie weakens the guarantees. Short leases limit the useful lifetime of a copied session.

The intended experience is to open the management page and let the exchange run in the background. Browser permission rules for public pages accessing private addresses may add a prompt. Real-tailnet browser testing remains on the roadmap.

How ByteBind differs from OIDC, and where tsidp fits

OpenID Connect gives applications a standard way to accept identity from a trusted provider. In the familiar authorization-code flow, the browser visits that provider, the application exchanges a code, and an ID token communicates the authentication result. OIDC itself doesn’t require passwords or a visible login screen. OpenID Connect specification.

ByteBind addresses a narrower requirement: a public application needs fresh participation through an authorized private-network device before granting management access. Its native exchange happens in the background, without an OIDC redirect. The result authorizes a short lease or one specific request. It establishes device participation; identifying the person using that device requires an additional mechanism.

tsidp turns Tailscale identity into standard OIDC/OAuth credentials for applications that already support those protocols. It can eliminate authentication prompts too. Its value is compatibility with existing applications and their identity integrations.

ByteBind’s focus is direct route protection, repeated private-network attestation, and transaction-bound authorization in applications adopting its bindings. Both aim to let users open an application without entering another password, but the integration and authorization model differ.

An application could use tsidp for its user identity and require ByteBind for a privileged operation. The ByteBind draft also describes an optional OIDC bridge, though the reference implementation does not implement it. Neither arrangement implies an existing tsidp–ByteBind integration.

From TailBind to ByteBind

The early drafts were called TailBind because the first provider was Tailscale. The protocol depends on an authenticated private path that can map a connection to a device and apply an authorization policy.

I renamed it ByteBind in draft 0.6. The current specification is draft 0.7.

Its cryptography uses HMAC-SHA256, HKDF-SHA256, and AES-256-GCM. Most of the design work concerns channel binding, authorization scope, and safe transaction consumption.

The reference implementation has unit and integration coverage and has completed browser exchanges in the test setup. Real-tailnet identity and certificate behavior, cross-node control traffic, browser permissions, and independent security review remain outstanding. I am publishing the draft to get those assumptions examined.

Read the technical companion for the endpoint flow and cryptographic transcripts, or the draft 0.7 specification and roadmap snapshot for the details and open questions.

This started with “how do I protect the management page for this server?” I am now considering an Internet-Draft as a way to get useful criticism of the answer. Yet again, a problem created by Bad Decisions may have produced a good one. I may have to rename that project.


ByteBind
© 2026 Bytes & Coffee Digital Studio

Daily Regrets: a bad decision with a morning routine

After giving Bad Decisions a browser client and multiplayer, I gave it an appointment.

Daily Regrets is a little bot that draws one round of Bad Decisions, makes a card out of the result, and posts it to @regret.bytes.coffee on Bluesky.

It posts once a day, around nine in the morning Pacific time.

The morning routine

The bot asks the live Bad Decisions API for a round from Cards Against Coffee. The response includes a prompt, its answers, and the finished sentence (the consequence). The bot renders that as a square image.

The card looks like the rest of Bytes & Coffee, down to the dark ink, cream lettering, burnt orange, and large serif type. Answers get italics and an underline so you can see exactly which words caused the trouble. The footer carries the issue number, date, and pack name.

Each card is drawn with Python and Pillow, with typography sized to fit the day's consequence. Even a terrible sentence deserves to look good. I've spent an unreasonable amount of attention on the orange.

A sample Daily Regrets card in burnt orange and cream. The consequence reads: “My expectations are in the floor, and corporate still surprises me with a tuna full of coffee.” The answer, “a tuna full of coffee,” is italicized and underlined. The card is dated September 30, 2026 and labelled Cards Against Coffee.

A sample card from the pre-launch test, rather than the first scheduled post. Cards Against Coffee by Michael Yazdani, based on the author's IRC messages (CC BY-SA 4.0).

The Bluesky post includes the consequence when it fits, an image description containing the full text and attribution, and a link back to the game. If the sentence is too long for the post, you can still read it on the card or in its alt text.

A daily dose

A fresh card arrives each morning around 9 a.m. Pacific. The schedule follows the local clock through daylight saving time.

The first card lands October 1. After that, the bot posts one new consequence a day.

Follow along

You can follow Daily Regrets for one fresh consequence each morning, or deal your own whenever you feel sufficiently responsible.

It is an unreasonable amount of machinery for one bad decision before coffee, which seems appropriate.

Bad Decisions: a joke that got multiplayer

I made a joke based on a picture on Discord. Thirteen days later (two minutes short, if you're counting, and I was), it had real-time multiplayer on the web. This is the first post on this blog because apparently that is how project scope works now.

What it is

Bad Decisions is a party card game engine. If you've played Cards Against Humanity you already know the shape of it: a prompt with blanks, a hand of answers, and a table of people choosing the worst possible combination.

The engine is a small Python service. It deals a prompt, fills the blanks from answer packs, and returns the finished sentence. The game calls that a consequence. It ships with three packs:

  • base: the Cards Against Humanity downloadable main game
  • coffee: Cards Against Coffee, a pack of tech and caffeine jokes
  • maha: the MAHA Pack

You can try it right now, with no account: bytes.coffee/bad-decisions/.

Peer Pressure: multiplayer

Reading randomly assembled cards by yourself gets old quickly. Peer Pressure turned it into the table game I had in mind.

  • Pick a room code. The first person to use it creates the room and becomes the host.
  • Invite at least two friends. Everyone gets a private hand of ten cards.
  • One player is the Responsible Adult for the round. Everyone else submits answers, which show up anonymously.
  • The Responsible Adult picks a winner, the table sees the consequence, and the role rotates to the next seat.

Rooms are deliberately temporary. Each room is its own small SQLite file on the server. Rooms expire on their own, and when the host ends one it's deleted outright. No accounts, no passwords, no email: joining sends a display name and a room code, and you get back a session token that lives in your browser.

Most of the work went into making a room hold together when real people click at the same time, retry requests, or wander away mid-round.

  • Every room has a revision number. Every change to the room bumps it, and every action a client sends says which revision it was looking at. If you act on stale state (say, trying to submit after the round already moved on), the server refuses with stale_revision instead of guessing. The client resyncs and decides whether the action still makes sense.
  • Every action carries a request ID. If the response gets lost and the client retries, the server recognizes the ID and replays the original answer instead of applying it twice.
  • The table repairs itself. If the Responsible Adult wanders off, the role passes to the next connected player. If that player had already submitted an answer, it goes back into their hand so they never judge their own card.

Regret: the terminal client

Not everyone wants a browser tab. So there's Regret, a command-line client:

brew install bytesandcoffee/tap/regret

regret                                   # deal a round from every pack
regret deal --packs coffee               # stick to one pack
regret deal --prompt-packs coffee --answer-packs maha
regret --list-packs

regret together badidea                  # join a Peer Pressure room
regret together badidea --tui            # ...in a full-screen Textual UI

regret together joins the same rooms as the web app, so half the table can be in a browser and the other half in a terminal. Line mode is plain prompts that work over SSH or in tmux, anywhere. --tui gives you a full-screen interface built with Textual.

The whole thing sits behind a public HTTP API with OpenAPI docs, so if you want to build a Discord bot, an e-ink daily draw, or a button that deals a round when you press it, the contract is already written down.

How I built it: two agents and a handoff file

I built Bad Decisions with Claude Code and OpenAI Codex, using the $20 plan for each. The choice was practical: each plan had a usage limit that reset every five hours. I handed the work over whenever one ran out.

The handoff depended on a file called HANDOFF.md. It described the project state: completed work, current assumptions, known breakage, and the finish line for the current task. The outgoing agent updated it. The next agent read it and carried on.

The idea resembles Kubernetes or Terraform: declare the state you want and let a controller work out the steps. I kept that state honest, reviewed the result, and handled production access myself.

A few things I learned:

  • Write down the state, not the plan. Plans go stale the moment work starts. A description of where you want to end up stays useful.
  • Let them review each other. Having one agent review what the other wrote is cheap, and it's a different set of eyes.
  • Keep the dangerous verbs for yourself. The agents don't push, publish, or deploy unless I ask them to, and they don't read secrets. Those rules are written into the repo's agent instructions.

Try it

It's an unofficial fan project, and the humor is exactly as mature as you'd expect. Bring friends you trust.