OAuth
By default /mcp is open on the loopback listener, and on the network listeners (-tailnet, -listen-access) it takes whoever the front door verified. Setting MCP_OAUTH turns lasso into a small OAuth 2.1 authorization server for its own /mcp, so MCP clients can authenticate to lasso directly. It is what makes per-host credentials and scope take effect, and it works beside Cloudflare Access: Access signs people and connectors in, and lasso's tokens narrow what an agent may reach.
Enabling it#
MCP_OAUTH='lasso-mcp:<a long random secret>' lasso serve
The value is client_id:client_secret, split on the first colon, so a base64 secret is fine. It is read from the environment only, never a flag, so it does not show up in ps. Optionally:
| variable | effect |
|---|---|
MCP_OAUTH |
client_id:client_secret of the pre-registered client. Unset: /mcp is open and every OAuth route answers 404. |
MCP_OAUTH_REDIRECT_URIS |
Comma-separated callback URLs. When set, the pre-registered client may only redirect to exactly these. |
Under systemd, put it in a 0600 environment file rather than the unit:
# ~/.config/systemd/user/lasso.service.d/20-mcp-oauth.conf
[Service]
EnvironmentFile=%h/.config/lasso/mcp-oauth.env
# ~/.config/lasso/mcp-oauth.env (chmod 600)
MCP_OAUTH=lasso-mcp:<secret>
Then systemctl --user daemon-reload && systemctl --user restart lasso. See systemd. At startup lasso logs one line saying whether OAuth is on, with the client id and the redirect rule:
mcp: OAuth on — client_id=lasso-mcp, grants=client_credentials+authorization_code, redirects: any https/loopback redirect (shown on the consent screen)
To check it on loopback:
curl -s -u "$MCP_OAUTH" -d grant_type=client_credentials \
http://127.0.0.1:8090/oauth/token
What /mcp accepts#
With MCP_OAUTH set, a request to /mcp passes with any of:
- a valid bearer token issued by lasso, in
Authorization: Beareror inX-Lasso-Token(for whenAuthorizationis taken, as it is behind Access's Managed OAuth); - the
UI_AUTHbasic credentials, whenUI_AUTHis set. This lets the lasso CLI, curl, and anything already holdingUI_AUTHkeep working without an OAuth dance; - no credential, from a caller a network listener verified (an Access identity, a tailnet device, a
tailscale serveuser). It gets the whole fleet, like any caller without a host; with a lasso token as well, the token's host scope applies. On a network listener a bearer token lasso did not issue is dropped, so an Access token never counts against the caller.
Anything else gets 401 with a WWW-Authenticate: Bearer challenge pointing at /.well-known/oauth-protected-resource, which is how an OAuth-capable MCP client discovers where to sign in. An MCP session is pinned to the client id its token resolved to: a later request on that session with a different client's token is refused.
The browser_* tools and /cdp accept the same credentials, with one addition: a per-host token must include lasso's own machine in its reach. See Browser tools.
Grants#
| grant | for | who may use it |
|---|---|---|
client_credentials |
machines: scripts, CLIs, agent sessions | The MCP_OAUTH client, and per-host clients made with lasso mcp-client add. Never a client that registered itself. No refresh token is issued; the client mints another with its secret. |
authorization_code with PKCE (S256) |
people: claude.ai and Claude Desktop connectors, other interactive clients | Any client, including self-registered ones, after a human approves the consent screen. |
refresh_token |
renewing an authorization-code session | The client the token was issued to. Each refresh rotates: the presented refresh token is consumed and a new pair is issued. |
Both kinds exist because claude.ai and Claude Desktop custom connectors cannot use client_credentials: they require the authorization-code flow with per-connection user consent. Their "OAuth Client ID / Client Secret" fields are a pre-registered client for that flow.
| lifetime | |
|---|---|
| access token | 1 hour |
| refresh token | 30 days |
| authorization code | 5 minutes, single use |
Tokens from lasso mcp-client token are the exception: they default to never expiring (see Agent scope).
Routes#
| route | gate | purpose |
|---|---|---|
/.well-known/oauth-protected-resource |
open | RFC 9728 resource metadata: names lasso as the authorization server |
/.well-known/oauth-authorization-server |
open | RFC 8414 server metadata: endpoints, grants, S256, scopes mcp and ui |
/oauth/register |
open | Dynamic Client Registration (RFC 7591) |
/oauth/token |
open | the token endpoint for all three grants |
/oauth/authorize |
UI_AUTH, or a caller a network listener verified |
the consent screen; issues authorization codes |
The metadata, registration and token endpoints are the credential-less half of the handshake, so they sit outside UI_AUTH, and a machine caller on a network listener may reach them. On the access listener they still sit behind Cloudflare Access like every other route. With MCP_OAUTH unset, every one of them answers 404, so nothing of lasso's competes with Access's Managed OAuth for a connector's discovery.
Registration and consent#
Registration is open, as the MCP spec expects: any client may register a name and its redirect URIs. Redirect URIs must be https, or http on a loopback host. A client that registers with token_endpoint_auth_method: "none" is public and proves itself with PKCE alone; any other gets a generated secret.
That is safe because /oauth/authorize is gated by UI_AUTH, and by the front door (Access, the tailnet) on a network listener. A registered client gets no token until a human passes that door and clicks Approve on the consent screen, which shows the client's name and the exact URL the code will be sent to. Only approve a redirect target you recognize.
Redirect URI rules:
- A self-registered client may only redirect to the URIs it registered, matched exactly.
- The pre-registered
MCP_OAUTHclient accepts anyhttpscallback, orhttpon loopback, because a connector's callback is not known when you mint the secret. The consent screen shows the target. - Set
MCP_OAUTH_REDIRECT_URISto lock the pre-registered client to an exact allowlist instead.
The ui scope#
Every token lasso issues carries the scope mcp, which reaches /mcp, /herdr-mcp and /cdp and nothing else: a plain token never reaches lasso's UI or /api, which have no per-host checks. A client that asks for scope=mcp ui in its authorization request gets a device token instead. Once a human approves it, that token also reaches the UI and /api: it satisfies UI_AUTH, and on a network listener it lets a machine past the rule that keeps machines on the MCP servers. The consent screen says so, asking to let the client "act as you in lasso: its whole UI and API, including the terminals and every file lasso can reach", and shows the scope. Only the consent screen grants ui; client_credentials never does.
A native client finds where to get one from lasso itself: with MCP_OAUTH set, a 401 from /api carries a WWW-Authenticate: Bearer challenge naming /.well-known/oauth-protected-resource and the scope mcp ui, beside the usual Basic challenge. Both metadata documents list mcp and ui in scopes_supported. The Mac app uses this flow: discovery, registration with a loopback redirect, and the authorization-code flow with PKCE.
Storage#
Authorization codes, access tokens, refresh tokens and registered clients' secrets are stored in lasso.db as SHA-256 hashes, so a copy of the database yields no usable credential, and connectors survive lasso restarts and self-updates. Expired codes and tokens are purged as the token endpoint is used.
claude.ai and Claude Desktop connectors#
Behind Cloudflare Access: Managed OAuth#
When lasso is published through Cloudflare Access, enable Managed OAuth on the Access application and add https://lasso.example.com/mcp as the connector URL. Access acts as the connector's OAuth 2.1 authorization server: it handles registration and the authorization-code flow, runs the login against your Access policy, and issues the tokens. lasso verifies the Access assertion on each request and sees you; it drops Access's bearer token rather than reading it as a bad lasso one. This works whether or not MCP_OAUTH is set.
Without Managed OAuth, the connector fails at registration ("Couldn't register with lasso's sign-in service"). Managed OAuth is a setting on the Access application, not a tunnel change.
Without Access: lasso's own OAuth#
When something other than Access publishes lasso (a reverse proxy listed in LASSO_HOSTNAMES), a connector can use lasso's own OAuth:
- Set
MCP_OAUTHas above and restart lasso. - In claude.ai, add a custom connector with the URL
https://lasso.example.com/mcp. - Under Advanced settings, enter the
MCP_OAUTHclient id and secret as the OAuth Client ID and Client Secret. (Leaving them empty also works: the connector registers itself through/oauth/register.) - Connect. Your browser lands on lasso's consent screen (behind
UI_AUTH), you check the redirect target and click Approve, and the connector gets its tokens.
Either way, connector tokens identify no host, so a connector sees every host lasso can address. Containment applies to per-host credentials.
Claude Code and other machine clients#
A client that can only carry a header takes a bearer token. Mint a long-lived one for a per-host client:
lasso mcp-client add --host myhost
lasso mcp-client token <client_id>
claude mcp add --transport http --scope user lasso https://lasso.example.com/mcp \
--header "Authorization: Bearer <token>"
Behind Cloudflare Access, add the service token's CF-Access-Client-Id and CF-Access-Client-Secret headers, and send the lasso token as X-Lasso-Token: <token> (see Installing on a host).
A client that can run the client_credentials grant posts its id and secret (basic auth or form fields) to /oauth/token and gets an access token valid for an hour. The lasso CLI reads a token from LASSO_MCP_TOKEN (see Shell commands).
Agent scope covers per-host clients, what they can see, and how to revoke them.