Reach isb serve remotely
isb serve never listens on an open port. It answers on a unix socket for
the host's own user and, with --listen, on loopback HTTP. To reach the web
UI, the MCP server and the REST API from anywhere else, put something in front
of loopback that decides who gets through: a Cloudflare Tunnel with Access
(SSO in front of everything, no inbound port), or your Tailscale network.
This page sets up both.
What --listen accepts#
--listen (ISB_SERVE_LISTEN) takes one or more addresses, comma-separated,
and refuses anything but loopback or a tailnet address (100.64.0.0/10,
fd7a:115c:a1e0::/48). Put a tunnel (or a reverse proxy) in front of loopback,
never an open port. Cloudflare Access
guards the loopback listeners; a tailnet listener is reached only by tailnet
peers and has no Access in front.
Whatever is in front, every HTTP caller still signs in as an isb user (a session, an API token), is a superadmin, or is an agent identity an org mapped; Access and the tailnet decide who reaches the port, isb decides what they may do (Users, roles and superadmins).
Cloudflare Tunnel and Access#
The pattern is the one herdr-mcp uses: Cloudflare runs the OAuth flow, isb stays the resource origin and checks the identity Cloudflare signs. Remote MCP clients such as Claude and ChatGPT connect with nothing but the URL; no secret is copied into them.
1. Serve on loopback#
# ~/.config/isb/serve.env
ISB_SERVE_LISTEN=127.0.0.1:8092
CF_ACCESS_TEAM_DOMAIN=https://your-team.cloudflareaccess.com
CF_ACCESS_AUD=your-access-application-audience-tag
ISB_PUBLIC_URL=https://isb.example.com
isb serve install writes ISB_SERVE_LISTEN=127.0.0.1:8092 into a new
serve.env by default; add the rest and systemctl --user restart isb.
Access is the front door; it does not replace
isb's own sign-in. Without the CF_ACCESS_* values the listener still serves
the web UI, the identity endpoints and the tools to callers with an isb
session or API token, and refuses anonymous calls (unless
--allow-unauthenticated, which is for local testing only). With them, a
request must also carry a valid Access assertion. ISB_PUBLIC_URL is what
invitation links, provider sign-in and passkeys are built from
(Sign-in).
2. Route the tunnel#
Add an ingress rule to the named tunnel's configuration, then validate and restart cloudflared:
- hostname: isb.example.com
service: http://127.0.0.1:8092
3. Protect it with Access Managed OAuth#
In Zero Trust, under Access controls, Applications:
- create an MCP server application for the hostname;
- add an allow policy for the people (or service tokens) who may use it;
- enable Managed OAuth in its advanced settings, allowing the redirect URIs of the clients you use (Claude, ChatGPT);
- copy its Application Audience (AUD) tag into
CF_ACCESS_AUD.
With both values set, every /mcp request must carry Cf-Access-Jwt-Assertion,
and so does every other request on the loopback listeners: the web UI, the
REST tools and /api/v1/auth/*, where isb's own sign-in then applies behind
Access. isb fetches the team's signing keys from
<team>/cdn-cgi/access/certs (cached for an hour, refetched for an unknown
key id, rate-limited), and verifies the RS256 signature, the issuer, the
audience and the expiry. A request that reaches the port by any other route
is refused. Do not put another OAuth server behind Access Managed OAuth:
Access replaces the origin's 401 handling by design.
An Access identity whose email belongs to an isb user acts as that user; one that does not is told to ask an org admin for an invitation.
Webhooks. Forges and registries cannot sign in to Access, so add an Access
bypass policy for /api/v1/webhooks/*. The daemon serves that path ahead
of its Access check, since each request is signed with the app's webhook
secret (Deploy apps).
4. Connect a client#
The MCP URL is https://isb.example.com/mcp, or
https://isb.example.com/orgs/ORG/mcp for one org. Claude adds it as a remote
(Streamable HTTP) MCP server; ChatGPT as a custom connector. The first
connection opens the Access login. Agents that cannot do a browser login use
an isb API token plus an Access service token's headers
(CF-Access-Client-Id, CF-Access-Client-Secret); the web UI's MCP page
writes those snippets (Agents and MCP).
Every call is logged to the daemon's stderr (the journal) with the caller's
identity, the tool, its duration and whether it failed, never its arguments,
and calls that change something are in the audit log.
Stacks record who deployed them (deployed_by).
5. Agents and superadmins through Access (optional)#
An org can map an Access service token's client id, or the email of someone
who is not an isb user, to a role in that org only (Agent
identities); the agent sends the
service token's CF-Access-Client-Id and CF-Access-Client-Secret. The same
CSRF, Origin, Content-Type and Host checks apply (the Host against the
public URL when --public-url is set).
Superadmins#
--superadmin-access alice@example.com,abc123.access (or
ISB_SUPERADMIN_ACCESS) gives the listed Access identities, users by email
and service tokens by client id, the unix socket's reach. Only a verified
assertion counts, so the flag is refused without both CF_ACCESS_* values,
and it needs --public-url (an Access superadmin's Host must be it). The
CF_Authorization cookie makes it ambient, so the same CSRF, Origin,
Content-Type and Host checks as for tailnet superadmins apply
(Superadmins); an MCP client that sends a
foreign Origin is refused as a superadmin. Emails match case-insensitively
and exactly, service tokens by client id, with no wildcards or domains.
A tailnet as the trust boundary#
To give people and agents on a Tailscale network superadmin access:
# ~/.config/isb/serve.env
ISB_SERVE_LISTEN=127.0.0.1:8092,100.86.22.100:8092
ISB_SUPERADMIN_TAILNET=someone@example.com,tag:agents
The tailnet address is this host's own (tailscale ip -4). A request from a
tailnet peer is a superadmin when tailscaled says its user's login, or one of
its node's tags, is on the list (a tagged node only by its tags). Everyone
else on that listener signs in as on any other (tokens, sessions). The
Host a browser sends must be the listen address, the node's MagicDNS name
(host or host.tailnet.ts.net) or the public URL's host. An empty list is
refused.
The identity comes only from the TCP peer address, asked of the local
tailscaled (whois over its LocalAPI socket, else the tailscale CLI on
macOS), cached for a minute per address. Forwarded headers
(X-Forwarded-For, Tailscale-User-Login) are never read. When tailscaled
does not answer, nobody is a tailnet superadmin, and the daemon says why
once.
A superadmin is root on the host in all but name: grant it to the people and tagged machines that administer the host, nobody else. For agents that should reach one org, give them an org API token, or map their tailnet login or tag to a role in the org (Agent identities):
# ~/.config/isb/serve.env: a tailnet listener is enough for org agent identities
ISB_SERVE_LISTEN=127.0.0.1:8092,100.86.22.100:8092
The org's owners and admins then add tag:agents (or a login) with a role in
Settings, Agent identities, and an agent on a node with that tag connects to
http://100.86.22.100:8092/orgs/acme/mcp with no token. The identity is asked
of tailscaled exactly as for superadmins; the Host, CSRF, Origin and
Content-Type checks apply, and it reaches that org only.
Checking it#
journalctl --user -u isb | grep -i superadmin # which superadmin sources are on, at start-up
curl -s http://127.0.0.1:8092/healthz # answers without auth
A superadmin can read the whole serving policy (listen addresses, Access,
the tool policy, what remote specs may ask for, each superadmin source) with
the host_policy tool or the web UI's Host page. Every flag is in
Configuration.