Lectern's normal story for "reach my own control plane from my phone" is
Tailscale: internal/auth resolves a caller's identity from tailscaled's
LocalAPI, so an owner on the tailnet never logs in at all. That works well
until the phone genuinely cannot run Tailscale — a work-managed device, a
borrowed one, a network that blocks it. This document is for that case: how
to put Lectern behind an ordinary public HTTPS tunnel and still keep it
locked down to the one person who owns it.
There are now two ways to do that:
- Device pairing behind your own HTTPS tunnel (this page). The tunnel
(Cloudflare,
tailscale funnel) terminates TLS in front of Lectern, so it sees every request in the clear. That is fine when you trust whoever runs the tunnel. An already-authenticated owner mints a short-lived, single-use code; a new device exchanges it once for its own long-lived, individually revocable credential. Seeinternal/pairing's package doc. - The end-to-end encrypted relay (relay.md). Lectern and the phone both connect out to a relay that only passes Noise-encrypted frames, so nobody on the path, including whoever runs the relay, can read or change the traffic. It needs no inbound port at all. Use it when you do not want to trust the path, or cannot open one. Happy (github.com/slopus/happy) also relays end-to-end encrypted traffic, with a different design (relay.md compares them).
Settings → Basics → Connect your phone (also at the top of Settings →
Phone & devices) lists the addresses a phone could use — the address the
browser is already on, the tailnet name, the LAN address (only on the same
network, unencrypted) and the relay — each with one line on what it means and,
when it cannot work, why and the one step that fixes it. It never offers a
loopback address: a QR code for 127.0.0.1 is useless to a phone. Show QR
code turns device pairing on if it was off (you pressed the button; that is
the deliberate choice) and mints a pairing code for the chosen address. The
list comes from GET /api/phone/addresses; when Lectern listens on loopback
only (the local runtime from lectern up) the tailnet and LAN rows say so and
point at lectern serve.
- Turn on device pairing. Either set
LECTERN_DEVICE_PAIRING=1in Lectern's environment, or flip the toggle in Settings → Phone & devices — either one turns it on; the env var cannot be overridden off from the UI (seepairing.Enabled). It is off by default: enabling it is a deliberate choice, not something a bare upgrade turns on for you. - Pick your tunnel. Two options that need no port-forward and no static
LAN IP:
- Cloudflare Tunnel (
cloudflared): run it on the same host as Lectern, pointed at Lectern's normal HTTP port (e.g.cloudflared tunnel --url http://127.0.0.1:9110, or a named tunnel with aningressrule to the same address). Cloudflare terminates TLS at its edge and forwards tocloudflared, which forwards to Lectern over plain HTTP on loopback. tailscale funnel: if this node already has Tailscale (most do — pairing is for the phone that doesn't, not necessarily the server),tailscale funnel 9110exposes the same port publicly with a real Let's Encrypt certificate, no second daemon needed.
- Cloudflare Tunnel (
- Set
LECTERN_AUTHexplicitly — do not leave it onauto/tailscale. See "The loopback trap" below for why.LECTERN_AUTH=tokenwith a strongLECTERN_AUTH_TOKENis the recommended baseline: treat that token as an admin bootstrap secret, used once to mint the first pairing code from a browser orcurl, never handed to the phone itself. Pairing is the phone's credential from then on. - Open
https://<your-tunnel-hostname>/pairon the phone (or scan the QR code minted in Settings → Phone & devices — see below), pair it, and use Lectern there exactly as you would over Tailscale.
internal/auth trusts a request from loopback as "a process on this
machine" (the CLI, the MCP server, a dispatched agent): KindLocal in
tailscale mode, and the owner in none mode. A tunnel that forwards to
127.0.0.1 (Cloudflare Tunnel's default, Tailscale Funnel) opens that
loopback connection on behalf of every internet visitor, so without a
guard they would all inherit that trust.
Lectern refuses loopback requests that show they were relayed from
outside, in every mode. That covers any request carrying
Cf-Connecting-Ip/Cf-Ray (Cloudflare) or Tailscale-Funnel-Request, or a
X-Forwarded-For/X-Real-Ip/Forwarded client address that is neither
loopback nor a tailnet IP. Such a request gets in only with a credential: the
static token or a paired device. Plain local callers, and tailscale serve
relaying a tailnet client, behave as before. The rules and their tests live in
internal/auth (cameFromOutside, tunnel_test.go).
Still set LECTERN_AUTH=token with a strong LECTERN_AUTH_TOKEN when you
expose Lectern. The guard depends on the tunnel sending a forwarding header,
and every mainstream one does. A bespoke proxy that strips all of them would
defeat it, and token mode does not extend loopback trust at all.
With pairing on and LECTERN_AUTH=token set, here is exactly what an
anonymous attacker on the public internet can reach:
GET /pair— a static page. It renders a form; nothing behind it is reachable without a code.- Static assets (
/style.css, icons, the manifest, …) — no different from any public web page. POST /api/pair/exchange— the one unauthenticated write. It accepts a code and a device name. Guessing a valid code means guessing 128 bits of randomness (internal/pairing.CodeTTLgives an attacker 5 minutes per code, andinternal/pairing.RateLimitercaps attempts at 10/minute per IP and 30/minute globally, with a 5-minute lockout on top of that once tripped) — not a realistic attack. A wrong, reused, or expired code all get the same generic "invalid or expired code" response, so this endpoint is not an oracle for narrowing down a real one.- Nothing else. Every other
/api/*route and every/term/*route requires either the static token or a valid paired-device credential (a cookie or bearer token that hashes to a row inpairing_devices). Both are opaque, high-entropy secrets stored hashed — the database never holds a value that authenticates anyone on its own (matchinginternal/oauth's existing convention).
What a paired device can do once paired: everything the owner's browser
can, including deciding approvals. This is deliberate, not an oversight —
see internal/auth's KindDevice doc comment. A paired phone IS the owner,
reachable without Tailscale; it is not a second, lesser account. That is why
minting a code requires CanDecide (the same gate an approval decision
itself requires) and why revoking a device is instant and final
(DELETE /api/pair/devices/{id}, checked on every subsequent request via
internal/pairing.Store.LookupDevice).
Trade-offs worth knowing about, not hidden:
- A device token idles out after
LECTERN_DEVICE_PAIRING's configuredidle_days(default 30, editable in Settings → Phone & devices) of no requests — but the browser cookie itself is set with a ~400-dayMax-Age(the practical ceiling most browsers enforce), so the cookie's own lifetime is never the limiting factor; the server-side idle check, refreshed on every authenticated request, is the real control. - CSRF protection (
SameSite=Strictplus an Origin/Referer check on every non-GET request authenticated by the device cookie) does not apply to a device token presented as anAuthorization: Bearerheader — that is a deliberate scope match to the actual risk (a browser tricked into sending a cookie it holds), not an oversight; a script that already has the raw bearer value out-of-band is not the CSRF scenario. - Settings → Phone & devices shows a hint when the request reaching it arrived over
neither loopback nor a tailnet address while pairing is off
(
GET /api/pair/settings'suntrusted_origin_hint) — but this cannot detect a tunnel that forwards to loopback (see "The loopback trap" above); it is a nudge for the cases it can see, not a complete guarantee. - A self-signed certificate on your own reverse proxy (for internal names such
as
*.example.internal) is irrelevant here: a public tunnel (Cloudflare,tailscale funnel) terminates TLS with its own trusted certificate, so the phone never has to trust anything self-signed.
A paired phone can install the PWA from the public tunnel origin exactly as
it would from a tailnet HTTPS origin — service worker registration and the
Web Share Target both only require a secure context (https://), which the
tunnel provides. Push notification eligibility (frontend/src/push.ts,
pushAvailability) checks browser feature support and secure-context status
only — it never checks or assumes a tailnet identity, so "Enable phone
alerts" works the same way for a paired device as it does for a tailnet
browser.
lectern serve (and bare lectern with no terminal, as a service runs it)
listens only on 127.0.0.1 unless something protects remote access: a
LECTERN_AUTH_TOKEN, or Tailscale identity (automatic when tailscaled is
running, or LECTERN_AUTH=tailscale). With one, it listens on every
interface as before. An explicit non-loopback LECTERN_HOST with no sign-in
is refused at startup, with the fixes listed, unless you pass
--insecure-listen (or set LECTERN_INSECURE_LISTEN=1) because something in
front of Lectern already checks who is asking. The Docker image listens on
the container's interfaces and needs a token or that flag; the Compose file
sets the flag while it publishes on the host's loopback only.
To say the obvious part plainly: putting a tunnel in front of Lectern with
LECTERN_AUTH=none (or auto, which quietly resolves to none off a
loopback listener) and pairing off hands unauthenticated control of every
session, task, and approval on this box to the entire internet. Pairing is
the thing that makes a public tunnel survivable; skipping it is not a smaller
version of this feature, it is not having it.