Bridge Auth With a Second Provider, Not a Backdoor
Bridge auth with a second provider, not a backdoor
When your managed-auth SDK doesn't exist in a client's language, the lazy fix is a shared static key or a path that skips auth entirely. Both are backdoors. The honest fix is to mint your own short-lived signed token from a service you already trust, and register it as a second validated provider, so the new client still resolves to a real user through the same authority.
The situation that forces this
Life Coach OS uses Clerk everywhere. The iOS and macOS apps get a Convex JWT through ClerkKit's session.getToken(template: "convex") — the SDK owns the session, the keychain, and the ~60-second refresh internally. No app code ever touches a raw Clerk refresh token.
Then you want a headless Rust daemon to talk to the same backend. There is no Rust Clerk SDK, and no way to drive Clerk's client-session protocol from outside it. So the obvious moves all rot:
- A static shared API key is a second class of long-lived secret with no user identity attached.
- A machine-to-machine token authenticates a machine, not a person, so your per-user authorization breaks.
- Letting the daemon hit the backend "around" the normal auth check creates a second authority path you now have to secure forever.
The move
Let the service that legitimately holds the user's session do the minting. The web app (which already runs Clerk) signs a short-lived RS256 JWT with sub set to the user's id, publishes a JWKS, and the backend adds a second auth provider that validates those tokens. The daemon only ever holds an opaque refresh token, which it exchanges for a fresh JWT at a route.
The trick that makes it cheap: set sub to the same id Clerk uses. Then the function that resolves a token to a user doesn't change at all. The headless path lands on the same user record as a first-party login. You added a client, not a special case.
Validation against code (2026-06-26)
Confirmed in the lcos build:
packages/backend/convex/auth.config.tshas a secondcustomJwtprovider (applicationID: "lcos-cli", RS256), spread in only whenLCOS_CLI_JWT_ISSUERis set.apps/web/lib/cliJwt.tssigns the JWT withsub = clerkId;cli_tokensstores the opaque refresh-token hashes; the web routes are/api/cli/{token,jwks,issue}.resolveIosUseris untouched, becauseidentity.subjectis the clerkId either way.
Why this generalises
Any managed-auth provider whose SDK doesn't travel to your client — a CLI, a background worker, an embedded device — hits this. The pattern is always: don't share a key, mint your own short-lived token from a trusted minter and validate it as a peer provider. The same instinct runs through The Bezos API Mandate and Agent-Native Interfaces: a new surface enters through the core's auth, never through a hole punched beside it.
See also
- Refresh Token Rotation Is Shared State — once you hold a refresh token, treat refresh as shared state
- Agent-Native Interfaces — the "MCP is a peer client, not a bypass" sibling rule
- Lcos — the project where this was built