Reading T3 Code

Chapter 8 · Architecture

Remote access and auth

How T3 Code lets a phone or a browser across the world control the machine with your code, and the rules that keep that from becoming a way in for anyone else.

9 min read

T3 Code’s server can run a shell on your computer and start an agent with full access to your files. Letting a phone control it from a café is the feature. Letting a stranger do the same would be a disaster. This chapter shows how the maintainers separate the two. They treat the network route as an untrusted detail, and they treat permissions as something you can hand out but never inflate. The ideas apply to any app with a login, including yours.

Many routes, one identity

TL;DR

There are several ways to reach your computer: home Wi-Fi, a private network app, SSH, or their cloud tunnel. They’re all just roads to the same server. The server stays the same server no matter which road you took.

A phone can reach a T3 Code server in four ways. It can connect directly on the local network, over Tailscale (a private network between your own devices), through an SSH tunnel, or through T3 Connect, the maintainers’ hosted tunnel service. The remote architecture doc opens by refusing to let any of these become special.

t3code/docs/internals/remote.md:3-7
Each connection joins a client to one environment over HTTP and WebSocket. The
environment owns providers, execution, files, and durable state. Direct access,
Tailscale, SSH, and T3 Connect change how the client reaches that server; they do
not introduce another execution model. See
[remote access](../user/remote-access.md) for setup.

An environment is one running server plus the machine and state it owns. Its identity doesn’t depend on its address. “An environment keeps its ID across server restarts and endpoint changes” (remote.md). This is what lets the client keep a saved connection while the laptop moves from home Wi-Fi to a hotel. The address changed, but the thing it points at did not.

Each transport has its own package, and they stay small because they only move bytes. packages/ssh has a config reader, auth, and a tunnel. packages/tailscale allows exactly three tailscale subcommands: status, serve, and debug. None of them decides who may do what. That decision lives in one place, the environment’s auth.

An address is a hint, not a proof

TL;DR

A saved address might now belong to a different computer, like an old IP on a new Wi-Fi. So the app first checks that the server on the other end is really yours before it hands over your login, and it never quietly falls back to a less safe road.

The trickiest part of remote access isn’t encryption. It’s knowing whether you’re talking to the right machine. The doc names the trap directly.

t3code/docs/internals/remote.md:27-30
Advertised endpoints are reachability hints. Only the connecting device can
prove that a route works. In particular, a host's loopback address refers to a
different machine when another device opens it. Endpoint selection must not
silently fall back to loopback when a shareable endpoint is unavailable.

“Loopback” is 127.0.0.1 or localhost, the machine’s name for itself. On your laptop it means your laptop. Opened on your phone, it means your phone. A server that advertised localhost as its address would send every remote client to the wrong place.

The client keeps an ordered list of routes and tries them in turn. Before it sends a credential over a saved direct route, it asks that address for the server’s public description. This way “a saved LAN address that a different machine answers on another network receives no credential” (remote.md). Picture your laptop at 192.168.1.20 at home. At a coworking space, a stranger’s printer might hold that same address. Without the check, your phone would send your token to the printer.

The same caution shows up when the client learns new addresses. A connected server reports the LAN and tailnet addresses it is bound to, and the client saves them. A token paired over HTTPS never starts using a plain HTTP address, so “a token paired over HTTPS never starts travelling unencrypted” (remote.md). Convenience never gets to lower the security a credential started with.

Pairing hands out scopes, never more than you have

TL;DR

Every login carries a list of things it’s allowed to do. When you invite another device, you can give it the same list or a shorter one, never a longer one. The server checks this itself and doesn’t trust labels or device names.

To connect a new device you pair it. An existing, trusted client creates a pairing link, and the new device exchanges it for a session. Every session carries scopes, named permissions defined once in the contracts package (auth.ts). Examples are orchestration:read, settings:write, terminal:operate, filesystem:write, and access:write.

The environment auth doc states the core rule in its first section.

t3code/docs/internals/environment-auth.md:9-18
Pairing delegates a set of scopes. Exchanging a bootstrap credential can narrow
that grant but cannot widen it. The default grant includes access and relay
management: `terminal:operate` is a shell as the server's user and
`orchestration:operate` can start a full-access agent, and either can mint a
session with every scope through `t3 auth session issue`, so withholding
management from the default would not be a boundary. A narrower client gets
explicit scopes. Creating another pairing link requires both `access:write` and
every scope being delegated. The
[auth handlers](../../apps/server/src/auth/http.ts) enforce this at issuance;
client labels and device metadata have no authorization role.

This paragraph is a model of honest security writing. It doesn’t pretend the default is tighter than it is. Anyone with a shell can mint any session, so leaving management scopes out of the default “would not be a boundary”. It gives you the real rule and the reason. The code that enforces “never more than you have” is a short loop in the pairing handler.

t3code/apps/server/src/auth/http.ts:459-464
            for (const delegatedScope of delegatedScopes) {
              if (!session.scopes.has(delegatedScope)) {
                return yield* failEnvironmentScopeRequired(delegatedScope);
              }
            }
            return yield* serverAuth.issuePairingCredential(args.payload);

Two smaller rules from the same doc are worth copying into any app you build.

  • Show a secret once. The list of connections shows pairing metadata, “never recoverable pairing secrets. Only the creation response returns the raw credential” (environment-auth.md). Otherwise read access to the list would let you copy someone else’s access.
  • Never fall back to something weaker. Cookies, bearer tokens, and DPoP tokens all map onto the same scoped session. DPoP ties a token to a key only that client holds, and “an invalid proof must fail rather than fall back to bearer authentication” (environment-auth.md).

Scopes are checked per request, not just at login. Every RPC method maps to a required scope in a table that must list every method, or the server doesn’t compile (RpcAuthorization.ts). For how that table connects to the rest of the API, see Contracts.

Separate doors for separate visitors

TL;DR

A login for their cloud service doesn’t let you into your computer. A share link only lets someone read one conversation. An AI tool signing in gets its own narrow kind of pass. Each visitor gets a separate door, and no key works on the wrong one.

T3 Code has several kinds of visitor, and the maintainers refuse to let one kind of credential stand in for another.

Cloud identity isn’t an environment login. T3 Connect uses Clerk for accounts and runs a relay that manages links and tunnels. Yet the auth doc’s opening says “a relay token is never an environment login” (environment-auth.md). The relay also stays out of your traffic. “After bootstrap, clients send application traffic through the environment’s tunnel hostname; the relay Worker does not proxy their HTTP or WebSocket sessions” (t3-connect.md). If the cloud service were breached, an attacker would have accounts, not shells on your machine. The relay is itself a Cloudflare Worker, in infra/relay.

A guest link isn’t a session. Sharing a thread gives one browser read access to one thread and a way to propose messages. Its tokens are HMACs, signatures computed from the thread and share ids with a server key, “so nothing secret is stored”. They “never enter SessionStore, so no RPC, WebSocket, or MCP path can accept one” (environment-auth.md). Revoking is just closing the share, because every token is derived from it.

AI tools are a separate audience. Agents that T3 Code didn’t launch, such as a hosted chatbot, sign in to /mcp through a narrow OAuth flow with PKCE (environment-auth.md). The approval page names the host that access goes to, because a link someone else sends you could otherwise hand them your access.

The pattern behind all three is the same. Decide which door each credential opens, and make every other door reject it by construction, not by a check someone might forget.

How your projects handle auth

TL;DR

fud already gets the hardest idea right: the website’s login and the AI tool’s login are different keys, and each door rejects the other. antre’s AI key opens the whole workspace, which is fine for one person but is the first thing to narrow if it grows.

fud applies the “separate audiences” rule cleanly, and its CONTEXT.md writes the rule down. An Owner is identified by a verified WorkOS token. “The web app uses a session token; MCP uses a Connect user access token with the MCP resource audience. Neither token is accepted at the other’s entry point” (CONTEXT.md). The code matches the words. The MCP check verifies the token’s issuer and its audience, then rejects any token minted for the web app’s own client.

fud/worker/mcp-auth.ts:20-35
    const { payload } = await jwtVerify(token, cached.keys, {
      algorithms: ["RS256"],
      issuer: env.WORKOS_AUTHKIT_ORIGIN,
      audience: env.MCP_RESOURCE_URL,
      requiredClaims: ["exp", "sub", "client_id", "sid"],
    });
    // ...
      payload.client_id === env.WORKOS_CLIENT_ID ||
    // ...
      throw new HttpError(401, "Use a Fud MCP user access token.");

The web check in worker/auth.ts does the reverse and requires client_id to equal the web client. Pinning the algorithm, requiring claims, and giving each failure a message that tells the user what to do is careful work. It is the same “no key works on the wrong door” idea T3 Code applies to relay tokens and guest links.

fud doesn’t have scopes in T3 Code’s sense yet. It has the beginning of them. MCP consents carry an access level, read, write, or revoked, set through /ai/access (worker/index.ts). That is a small, explicit permission model, which is exactly the right size for a personal finance app.

antre’s MCP endpoint works differently. It reads a bearer token, rejects anything missing or over 512 characters, then asks Convex for the workspace that owns that secret (src/routes/mcp/+server.ts). It checks the workspace is active before handing the request to the MCP handler. That is a clean boundary, and Convex makes the decision, not the route. The token has one level, though. Whoever holds it can do everything the MCP tools can do, including drafting and scheduling posts. For a single-owner tool that is a reasonable trade. Two T3 Code habits would still apply cheaply. Show the secret only once, and if you ever add a second kind of client, give it a narrower grant instead of the same key.

You don’t need T3 Code’s DPoP, pairing links, or relay. Those exist because their server can run a shell. Their rules transfer anyway. An address isn’t an identity, a credential never widens, and each door has its own key.

Try it

TL;DR

Write down which key opens which door in your apps, add a read-only option to antre’s AI key, and check that fud really rejects the wrong key at each door.

  1. Write the door table. In antre/README.md (or a new antre/docs/auth.md), make a table with one row per entry point: the web app, the /mcp route, and any webhook or cron. For each, list which credential it accepts, who issues it, and what it can do. Copy the style of fud/CONTEXT.md’s Owner entry. It worked if every row has exactly one credential and you can say in one sentence why the other credentials fail there.
  2. A read-only key for antre. Add an access: "read" | "write" field next to the workspace secret in Convex, and have the MCP route refuse the drafting and scheduling tools when access is "read". Check it by calling a write tool with a read key and confirming a 403 that names the missing permission. Make sure search_posts still works with that key.
  3. Prove fud’s doors. Add a test in fud/tests that signs a JWT for the web client id and sends it to the MCP endpoint, and another that sends an MCP-audience token to a web route. You can use jose with a local key and a stubbed JWKS. Both should get 401. Then temporarily delete the payload.client_id === env.WORKOS_CLIENT_ID line in worker/mcp-auth.ts and confirm the first test fails. That shows the test guards the rule and not just the happy path.

Text size

Theme