localauth
Finish CLI logins on a headless server from your phone. localauth.dev
Many CLIs log in with OAuth and a loopback callback: they open
https://provider/authorize?...&redirect_uri=http://localhost:8085/callback and wait on that
port. On a remote server there is no browser, and if you open the link on your phone, the
provider sends you to localhost on the phone, where nothing is listening.
localauth bridges the gap:
localauth run -- <login command> runs the CLI and catches the sign-in URL it opens or prints.
- The login shows up on localauth.dev, titled after the tool, with its live terminal output.
- Open it on your phone and sign in as usual. The browser ends on an
http://localhost:… page
that does not load; tap Paste and finish (or paste the address to your agent, which runs
localauth finish).
- The address goes back to the server, which checks it and hands it to the waiting CLI. The login
completes on the server and the tokens never leave it.
Login data is encrypted at rest by default. Turn on end-to-end encryption with a PRF-capable passkey on the Account page.
It works with any CLI that uses a loopback redirect: codex login, claude mcp login,
gcloud auth login, agent login (Cursor), kargo login --sso, wrangler login, and others.
Device-code logins (gh auth login) show up too; there is just nothing to paste.
Quick start
Node.js 24.3 or newer on the server.
npm install -g @localauth/cli
localauth login
localauth run -- codex login
localauth login prints a link. Open it on your phone, sign in to localauth.dev (GitHub, Google,
a passkey or a code by email) and approve the machine. From then on, logins you run with
localauth run appear at localauth.dev.
Every run prints a direct https://localauth.dev/r/<id>#s=<secret> link. Open it without
signing in, in either encryption mode. The fragment key stays on the machine and in that browser
tab's sessionStorage (reloads work); the address bar drops it immediately. --no-link disables
this link. The signed-in list remains available.
The link is a key to one login. Anyone holding it can finish that login, including by signing
in to their own provider account (leaving your CLI signed in as them). Send it only to the owner;
never post it in a shared channel. Expiry, dismissal or disconnect ends access. Pruning erases
expired payloads and queued messages while retaining run IDs, so a late sync cannot revive a link.
Commands
localauth login [--url <service>] [--name <name>]
localauth run [--input] [--no-link] [--no-retry] [--retry-window <minutes>] -- <command>
localauth ask <label> [--secret] [--timeout <minutes>] [--env NAME -- <command>]
localauth ask [--title <text>] [--description <text>] --field NAME=Label [--secret-field NAME=Label]...
[--optional NAME]... [--timeout <minutes>] [--json | --out <file> [--force]] [-- <command>]
localauth finish
localauth list
localauth show <id>
localauth status
localauth verify
localauth logout
localauth finish matches the address to the waiting login by port and OAuth state, so a user
can paste it into a chat with an agent and the agent finishes the login. It reads the address from
stdin (printf '%s\n' '<address>' | localauth finish) so it stays out of the process list; an
argument works too.
When a command fails, localauth run keeps the failed run available for Retry on the page
for 10 minutes. Tapping it reruns the same command as a new attempt of the same login, with fresh
links and output. There are at most five attempts. --retry-window <minutes> changes the wait
(maximum 60 minutes); --no-retry exits immediately on failure. Success exits immediately.
Ctrl-C or SIGTERM stops the wait. With no retry request, the wrapper exits with the last command's
exit code. A disconnected machine cannot accept retries and exits immediately.
The run already shows Failed while the process waits. Agents should check localauth show
for the result instead of waiting for the background process to exit.
Type into a command or request a value
If a CLI asks for a value in its terminal, enable page input for that run:
localauth run --input -- my-cli login
Type under Terminal output and press Enter or Send. The box highlights an unfinished
: or ? prompt; Secret defaults on for key, token, password and secret prompts. The machine
writes the text followed by a newline to the command's terminal. Local keyboard input continues
to work too. Without --input, the machine ignores page input. Exact occurrences of page input
are replaced with ••• before terminal output is printed or synced, including echoes split
across chunks and later retries. This does not hide transformed or encoded copies printed by a
command. Never put credentials in command arguments or ask for them in chat.
When an agent needs credentials, collect related values together and pass them directly to a
command's environment:
localauth ask --title "App credentials" \
--description "Create an app at https://example.com/settings/apps and copy its client ID and secret." \
--field 'CLIENT_ID=Client ID' --secret-field 'CLIENT_SECRET=Client secret' \
-- my-tool configure
Give the owner the printed /r/…#s=… request link. The page shows the title (or first field's
label), description, machine and fields in flag order, then Send and Decline. Required
fields must be filled before sending; Enter in the last field sends. Secret fields have a
Show/Hide toggle. Descriptions preserve newlines and link HTTP(S) URLs in a new tab;
HTML and Markdown are displayed as text.
Groups contain 1–10 unique fields. Names match ^[A-Z_][A-Z0-9_]{0,63}$; labels and title are up
to 120 characters, descriptions up to 2000. Fields are required unless named with --optional NAME.
Each answer can contain up to 12,000 characters. Command-control names are refused, including
PATH, HOME, LD_*, DYLD_*, NODE_OPTIONS, NODE_PATH, BROWSER, LOCALAUTH_*, shell startup
variables (ENV, BASH_ENV, SHELLOPTS) and Python/Perl/Ruby loader variables.
With -- <command>, each answered field becomes its named environment variable. localauth never
prints these answers and exits with the command's code; choose a command that keeps credentials
out of its own output. Optional fields left blank on the page are omitted. To write credentials
to a new private config file instead:
localauth ask --field 'ENDPOINT=Service URL' --secret-field 'API_TOKEN=API token' \
--out ./service.env
--out creates a file with mode 0600, refuses to overwrite unless --force, and prints only its
path. File contents and the default grouped stdout format are NAME="value" dotenv lines with
JSON-style backslash escaping for quotes, backslashes and control characters. Read them as data
with a compatible dotenv reader; do not source them as shell scripts. --json prints one JSON
object instead. Choose one output mode: a command, a file, JSON, or default dotenv stdout.
The original single-field forms still work:
localauth ask "Linear API key" --secret --env LINEAR_API_KEY -- my-tool configure
KEY=$(localauth ask "API key" --secret)
The first passes the answer to the command without printing it; the second prints only the bare
value and newline for script capture. Links and diagnostics always go to stderr. Decline or
timeout exits 1 with no stdout. Requests expire in 15 minutes; --timeout <minutes> allows up
to 60. Answers are removed atomically when the originating machine takes them. Invalid answers
leave the request waiting with a note; values never enter snapshots, notes, logs or the connection
config. Group titles, descriptions and field definitions use the same encryption as run details.
Enable end-to-end encryption before exchanging secrets on the account page. In default mode
the service can read typed input and answers, and the page says so. Direct link pages encrypt
input and answers with their link key in either mode. The Secret toggle only controls display.
Agent skill
skills/localauth/SKILL.md teaches coding agents (Claude Code, Codex, …) to use localauth whenever
a CLI login needs a browser on a machine you are not sitting at. Copy or link it into your agent's
skills directory, for example ~/.claude/skills/localauth.
Public API and browser tools
The API reference and
OpenAPI 3.1 document describe the public /api/v1 API.
Changes within v1 are additive; incompatible changes get a new path version.
The website and CLI 0.2.0 use this same API. Better Auth stays at /api/auth/*, including the
iOS app's OAuth endpoints.
Breaking upgrade to CLI 0.2.0. The old /api/me, /api/account/*, /api/runs/*,
/api/machines/*, /api/pairings/*, /api/verifications/*, /api/r/* and /api/tokens/*
routes are removed. /api/m/* returns HTTP 410 with an instruction to run
npm install -g @localauth/cli. Existing scripts must use /api/v1 paths, direct single
resources, paginated lists and the nested error shape. Open website tabs should reload.
Machine tokens, saved configuration, account keys and encrypted envelopes do not need conversion.
CLI 0.1.0 stops working once the Worker is updated, and CLI 0.2.0 needs the updated Worker:
update both together. Self-hosted Access deployments must move their machine-route bypass policy
to /api/v1/machine/* first.
Create a personal access token in Account, API tokens. Choose a name, permissions and,
optionally, an expiry. Copy it when it appears; the service stores only a hash of it.
The card lists creation, last use and expiry, and lets you revoke a token immediately.
curl https://localauth.dev/api/v1/runs \
-H 'Authorization: Bearer lat_YOUR_TOKEN'
Permissions are runs:read, runs:write, machines:read and machines:write.
Personal tokens cannot change the account, encryption keys, passkeys, other tokens or emoji
verification. In Cloudflare Access mode, personal tokens and OAuth are disabled.
The website uses the Access JWT and the CLI uses its machine token.
Responses use camelCase JSON and epoch milliseconds. Lists return { data, nextCursor }
and accept limit (1–100, default 50) and cursor. Scripts and native clients use the
Authorization header. The website can also use its same-origin session cookie or Access JWT.
Browser credentials require matching Origin or Sec-Fetch-Site: same-origin on reads and
mutations; contradictory signals are rejected. An Authorization header always takes precedence,
even if invalid. Cross-origin clients use explicit header credentials; CORS never permits
cookie credentials. Errors return { error: { code, message } }; rate limits return 429
with Retry-After. Machine tokens and link capabilities have separate routes and authority.
The iOS app (client localauth-ios) signs in with Better Auth's OAuth provider:
/api/auth/oauth2/authorize, /api/auth/oauth2/token and /api/auth/oauth2/revoke, with S256
PKCE and an exact redirect allowlist. Access tokens last an hour; refresh tokens last 90 days and
rotate on use. A retry within 10 seconds gets the same new pair; reusing a rotated refresh token
later revokes all of the app's tokens for that person. The app stays signed in independently
of the browser session it signed in through (it asks for offline_access). The app revokes its
refresh token on sign-out, deleting the account removes every token, and disabling the client
row switches the whole app off. Third-party clients, registration and
consent are not offered. The plugin has a documented race in that revocation, which this
repository patches; see patches/README.md.
End-to-end data stays sealed through the API. A client that needs plaintext must hold the
account private key, obtained locally from a passkey PRF wrap, and implement the
encryption protocol and fixed test vectors. An OAuth token alone cannot
decrypt it. Machine-side callback checks and account-key pinning remain mandatory.
On browsers supporting WebMCP, dashboard and link pages register login tools and the dashboard
and device page register machine approval tools. They use the page's existing decryption,
sealing, callback checks and confirmation dialogs. A link page sees only its run. A locked
browser asks the person to unlock on the page. Machine output and names are untrusted data.
There are no WebMCP tools for emoji verification, account, encryption, passkey or token changes,
sign-out or deletion. A person still compares emoji after approving an E2E machine.
Encryption
By default, any browser signed in to your account can read your logins. The Worker encrypts
run data, pasted callbacks, typed input and answers with AES-256-GCM before writing to D1, using a key derived from
its DATA_KEY secret. A database or backup leak alone cannot decrypt them. The service can
read the data in this mode, including pasted callbacks. Some tools put API keys in callbacks
(for example, Framer), rather than one-time codes.
On the Account page, add a PRF-capable passkey and accept Turn on end-to-end encryption.
The browser generates an account ECDH P-256 key pair and wraps the private key using the
passkey's PRF output. Only the public key and wrapped private key reach the service. Registration
can require an extra passkey prompt to obtain PRF. Adding another passkey offers a wrap for it,
with an assertion from an existing wrapped passkey first. A passkey without PRF can still sign in,
but cannot unlock encrypted data; use iCloud Keychain, Google Password Manager or a compatible
security key. Passkeys are only added after signing in, never used to create accounts.
Passkey sign-in unlocks automatically. After email or social sign-in, tap Unlock with passkey.
The browser keeps the account key in IndexedDB as a non-extractable CryptoKey; signing out clears
it. Synced passkeys can unlock on new devices. There is no browser pairing step, and pairing keys never
appear in connection links: localauth login prints only https://localauth.dev/device?code=XXXX-XXXX.
Machines keep an ECDH private key and token in ~/.config/localauth/config.json (0600). Each run
uses a fresh AES data key wrapped for the account and the originating machine, so localauth list,
show and stdin finish work there too. E2E browser callbacks use authenticated static ECDH between the account and machine keys.
The originating CLI authenticates its stdin callbacks with its own private key.
Input and answers on the E2E account page also use static-static P-256 ECDH (account private
key × machine public key), HKDF-SHA256 and AES-256-GCM. A pinned machine refuses plaintext,
public-key-only envelopes, and messages from a different account key. The service has neither
private key, so it cannot forge or alter these messages. Input is bound to the current attempt
and deduplicated by an authenticated message id. Ciphertexts bind their kind and record to prevent swapping.
A machine trusts the account public key only after a short emoji check, so the service can't
hand it a key of its own. localauth verify (and localauth login on an end-to-end account)
prints a link; the page and the machine exchange ephemeral ECDH keys through the service, the
machine having committed to its key before seeing the browser's, and both derive four emoji
from the shared secret and everything exchanged (the 64 emoji of Matrix's SAS verification,
shown with their names so different emoji fonts still compare). The terminal prints the four;
the page offers four rows of them and "None of these match", so nobody types emoji. Picking the
right row sends a MAC of the exchange that the machine checks before pinning the key. A service
in the middle would have to guess the emoji, a 1 in 2^24 chance per attempt. The browser also
stores the account's MAC over the machine's key so other browsers show it as verified.
Until it is verified, a machine refuses to run logins for an end-to-end account. If the service
changes the pinned key, the CLI stops before running a command, sending plaintext or accepting
input and answers, until localauth verify passes for the new key; if it removes the key, the
machine must reconnect with localauth login. Reset end-to-end encryption deletes the account key and all passkey wraps;
old encrypted runs become unreadable and machines must reconnect. Existing default-mode runs
stay readable until they expire; enabling E2E protects new snapshots, not previously stored data.
Callback checks always happen on the machine: only loopback on the expected port with matching
OAuth state reaches the waiting CLI. Machine names, account identity, times, coarse run states
and ciphertext sizes remain visible to the service. The page code comes from the site, so E2E
does not protect against an actively compromised service delivering altered browser code.
Direct run links derive separate access and AES-256-GCM keys from a fresh 32-byte secret using
HKDF-SHA256, with run-specific contexts. The service stores only the access token's SHA-256 hash
and a separately sealed snapshot. It never sees the fragment secret, and cannot read or forge
link-page data, callbacks, typed input or answers. This protects the link copy in both modes; the account copy remains
readable by the service in default mode. Ciphertexts bind the run and message kind. As with E2E,
this assumes the browser executes the intended client code.
Retry requests are metadata visible to the service in both encryption modes. In end-to-end
mode the service can at most make the machine rerun a command it already ran, within the retry
window and five-attempt cap. The machine always uses its original arguments; the service cannot
supply a command or change those arguments. Callback checks remain mandatory on every attempt.
The migration from the first hosted release (before end-to-end encryption) revokes old machine registrations and drops their short-lived runs because
the old symmetric-key protocol is incompatible. Reconnect with localauth login --name <name>.
Self-hosting
localauth runs on Cloudflare Workers with D1. You need a Cloudflare account and a domain on it.
git clone https://github.com/lcandy2/localauth.git && cd localauth
npm install
npx wrangler d1 create localauth
In wrangler.jsonc, set your domain in routes and PUBLIC_URL. Then pick how people sign in.
Cloudflare Access (no accounts of its own). Put the Worker's domain behind an Access
application. localauth verifies the Access token on every request and treats each Access identity
as a person; there is no sign-in page.
"vars": {
"PUBLIC_URL": "https://auth.example.com",
"AUTH_MODE": "access",
"ACCESS_TEAM_DOMAIN": "https://<team>.cloudflareaccess.com",
"ACCESS_AUD": "<the Access application's AUD tag>"
}
Access mode always uses server encryption; it has no passkeys. DATA_KEY is required in both
production modes. Set it to a securely generated random value and retain it across deployments:
npx wrangler secret put DATA_KEY
Machines talk to /api/v1/machine/* without a browser. Either add a Bypass policy for
/api/v1/machine/* (those routes check machine tokens themselves), or create an Access service
token, allow it with a
Service Auth policy and set LOCALAUTH_ACCESS_CLIENT_ID and LOCALAUTH_ACCESS_CLIENT_SECRET
before localauth login. Remove the send_email binding if you don't use email.
Accounts (Better Auth). Keep AUTH_MODE at better-auth and set secrets:
npx wrangler secret put BETTER_AUTH_SECRET
npx wrangler secret put GITHUB_CLIENT_ID
npx wrangler secret put GITHUB_CLIENT_SECRET
npx wrangler secret put GOOGLE_CLIENT_ID
npx wrangler secret put GOOGLE_CLIENT_SECRET
npx wrangler email sending enable <your domain>
Sign-in options without credentials are hidden. Passkeys need no setup.
Sign in with Apple. In Apple Developer, create an App ID with Sign in with Apple enabled,
then a web Services ID associated with that primary App ID. Configure the service's domain
and the return URL https://YOUR_DOMAIN/api/auth/callback/apple. Download a Sign in with
Apple .p8 key, keeping its Key ID and your Team ID. Set these Worker secrets:
npx wrangler secret put APPLE_CLIENT_ID
npx wrangler secret put APPLE_TEAM_ID
npx wrangler secret put APPLE_KEY_ID
npx wrangler secret put APPLE_PRIVATE_KEY
The Worker signs and caches Apple's ES256 client secret and renews it before its 180-day
expiry. There is no manually maintained client-secret JWT. Apple appears first on the sign-in
page once all four values exist; it can also be connected or disconnected in Account.
Apple's cross-site form callback is allowed only at its exact POST callback endpoint; OAuth
state validation still applies. Apple requires an HTTPS domain for callbacks.
When the app's bundle ID is decided, enable its Sign in with Apple capability and set
APPLE_APP_BUNDLE_ID to accept native identity-token audiences as well as the web Services ID.
Set OAUTH_IOS_REDIRECT_URIS to the comma-separated exact redirect URLs selected for the app
(a universal link and/or its custom scheme). No value is invented by the server, and an empty
allowlist disables authorization. The app's associated-domain file and push notifications
come with the iOS app; they are not part of this change.
Apple's Hide My Email can supply a private relay address. Automatic account linking uses a
verified email, so a relay address creates a separate localauth account from a different
GitHub or Google email. To use the same account, sign in to that localauth account first,
then connect Apple from Connected accounts. Configure Apple's private email relay sender
registration if you send sign-in email to relay addresses.
Then create the tables and deploy:
npm run db:migrate
npm run deploy
Connect machines with localauth login --url https://auth.example.com.
Develop
cp .dev.vars.example .dev.vars
npm run db:migrate:local
npm run dev
npm test
npm run typecheck
npm run test:e2e
npm run design:qa
While npm run dev runs, http://localhost:8787/design shows every page in every state (signed
out, waiting logins, failed retries, emoji verification, dialogs, …) with made-up data, and
/design/<id> opens one with a switcher in the bottom right for states and light/dark. They are
for tuning the design: no real data, no API calls, and they don't exist in deployed Workers.
The browser harness uses playwright-core and Chromium at
~/.cache/ms-playwright/chromium-1248/chrome-linux64/chrome (override with CHROMIUM_PATH).
It uses the local D1 and .dev.vars, generates unique test accounts, writes artifacts to a
printed temporary directory, and stops its Worker and CLI processes on completion. Stop any
existing dev server before running it. WebAuthn tests use a CDP virtual authenticator with PRF.
Local development can omit DATA_KEY: it derives from BETTER_AUTH_SECRET, or a fixed dev value.
Any PUBLIC_URL outside localhost/loopback requires DATA_KEY.
app/ is the Worker: Remix 3 router and components, Better Auth, Drizzle on D1.
cli/ is the localauth command; shared/ is used by both (encryption, callback checks).
- Change tables in
app/db/app-schema.ts, then npm run db:generate for a migration.
npm run auth:schema regenerates Better Auth's tables after changing its plugins.
Limitations
- The paste step stays: a phone browser's
localhost is the phone, and providers refuse a changed
redirect_uri, so the callback cannot be redirected to the server.
localauth run uses the system script utility for a pseudo-terminal (util-linux or BSD);
without it the command runs with plain pipes.
- Logins expire an hour after they start; requesting a retry and starting its next attempt extend that expiry by an hour.
License
MIT