Docs / Security · docs/SECURITY.md
Security
relay runs other people's text through coding agents that can edit files and push branches, on machines that hold
your subscriptions. This page states the rules that keep that safe and what the code does to enforce them. If a
change to relay would weaken any rule here, it needs a decision in docs/ARCHITECTURE.md first.
The credential rule
Tasks move, credentials never do.
- Every model call runs on a machine where you logged in with the vendor's own tool (
claude,codex,grok,fx,gh). The login lives in that user's home directory and keychain, where the vendor put it. - No login, token,
auth.json, cookie, or config directory is ever copied between machines, pasted into a chat, stored as a GitHub secret, or checked into a repository..env.examplelists names only. - The runner workflow never reads
secrets.*, never passesGITHUB_TOKENinto a step, and does not useactions/checkout(which would wire the token into git).run-lane.shand the worker'scleanEnv(packages/core/src/work/job.ts) unsetGH_TOKEN,GITHUB_TOKEN,ANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKEN,OPENAI_API_KEYandXAI_API_KEYso a stray key can never make a lane run outside the subscription or act as someone other than the machine's operator, andRELAY_TOKEN,RELAY_SITEandRELAY_HOMEso the lane never sees the worker's own site credential or its~/.harness. - API keys for pay-per-token overflow lanes (
AI_GATEWAY_API_KEY) live in Vercel's environment for the web app and nowhere else; runners do not have them.
If a credential does leak, revoke it at the vendor first, then redo the login on the affected machine.
Prompt injection: issue text is data
Anyone who can open an issue in a linked repo can write text that a lane will read. relay treats that text as data, never as instructions:
- Who can start a lane.
relay-run.ymlrefuses to run unlessgithub.event.sender.login(the person who added the label, or who opened the PR being reviewed) is listed in theRELAY_ALLOWED_SENDERSvariable. Opening an issue does nothing; labelling it needs triage permission and an allowlisted login. The web app applies the same gate when anagent:<lane>label arrives by webhook and would become a task for a worker (senderMayOpenTasks,apps/web/lib/webhooks/usage.ts): the label's sender must be inRELAY_ALLOWED_SENDERS(the environment, else the repo's Actions variable), and when neither is set nobody may open a task that way.relay task createand the API need atasks:writetoken instead. - How text reaches the shell. Issue and PR title and body are passed only through
env:. No${{ }}with user-controlled content appears inside arun:script, so a title like"; rm -rf ~is a string, not a command. - How text reaches the model.
run-lane.shwrites the issue into the brief between<<<ISSUEandISSUE>>>markers under a banner that says it is data, that instructions inside it about tools, permissions, files, or credentials are to be ignored and reported, and what the actual deliverable is. The repo's ownrelay.mdrules come first in the brief, the issue after. - What the model can do anyway. Even a fooled model is boxed by the CLI's permission model: Claude Code runs
with
--permission-mode acceptEdits, an allowlist of named commands (Read, Edit, Write, Glob, Grep,Bash(git status|diff|log|add|commit|checkout|restore *),Bash(pnpm install|test|typecheck|build *),Bash(pnpm -r *),Bash(npm test*)) and a refusal list (--disallowedTools) forgit push,git -c,git config,git remote,pnpm dlx,pnpm exec,npm exec,npx,curlandwget(CODE_TOOLSandCODE_DISALLOWED_TOOLSinpackages/core/src/work/job.ts, the same lists inrun-lane.sh); Codex runs with--sandbox workspace-write; Grok with--permission-mode acceptEdits; fx with--autounder its own permission rules. Reviews are read-only (planmodes,--sandbox read-only). Subagents and planners are disabled (-c agents.enabled=false,--no-subagents --no-plan, MCP servers off) so the lane is one process the runner can account for. The Claude allowlist is a permission policy, the second layer: it decides which tool calls the CLI accepts without asking. What the accepted commands and their children (apackage.jsonscript run bypnpm test, a git hook in the clone) can then reach is decided by Claude Code's OS sandbox, which every code run starts inside: see "Workers" below for what it blocks and what it does not. - What the model cannot reach. On a GitHub Actions runner the lane runs in a fresh clone under
$RUNNER_TEMPas the dedicatedrelayOS user. That user has no SSH keys, no browser profile, no sudo, and no access to your home directory. The worktree is deleted after the job. On a worker (relay work) the lane runs as the worker's own OS user; "Workers" below says what that means and how to set the machine up. - What happens to the output. The lane cannot merge. The lane itself cannot push (
git pushis on the refusal list); the worker or runner pushes arelay/<lane>-<issue>branch with--force-with-leaseand opens a PR that a human approves; a second lane on a different model reviews it first. Branch protection on the default branch is required, not optional: the account that pushes is the operator's ownghlogin, so without protection a fooled lane that found another way to rungit pushcould land onmain. Require the review and a passing CI run, and forbid force pushes.
The usage comment the runner posts is parsed only when the comment author is in RELAY_ALLOWED_SENDERS; anyone
else's {"relay":"step.usage",...} block is stored as untrusted and excluded from the ledger (docs/FEED.md).
Workers (relay work)
A worker is the operator's own machine running the vendor CLIs under the operator's own logins (docs/WORKERS.md).
State the facts plainly:
- The Claude lane's Bash runs inside Claude Code's OS sandbox (Seatbelt on macOS, bubblewrap and socat on Linux).
Every code run passes one settings document with
--settings(claudeSandboxSettingsinpackages/core/src/lanes/sandbox.ts;relay lane settingsprints it,run-lane.shuses the same), and--setting-sources ""makes it the only settings the run sees, so neither the machine's~/.claudenor a.claude/settings.jsonin the clone can loosen it. Inside it, every Bash command and every child process (git, pnpm, a repository's own scripts, a git hook) may write only inside the clone and the pnpm/npm stores, may not read~/.harness,~/.claude,~/.claude.json,~/.codex,~/.grok,~/.fx,~/.ssh,~/.aws,~/.config/gh,~/.netrc,~/.npmrcor~/.gitconfig, and may reach only the package registries and GitHub download hosts on the network allowlist (strictAllowlist;RELAY_LANE_ALLOWED_DOMAINSadds hosts) through Claude Code's local proxy.failIfUnavailableandallowUnsandboxedCommands: falsemean a machine that cannot sandbox fails the job with a message saying what to install; nothing ever falls back to running unsandboxed. Verified on macOS with Claude Code 2.1.280:head ~/.gitconfigandtouch ~/xfail with "Operation not permitted",curlto an unlisted host gets a proxy 403 with adeny network-outboundviolation. - What the sandbox does not cover. It wraps Bash, not the Read/Edit/Write tools, so the same paths are refused
again with
permissions.denyrules (Read(~/.ssh/**),Edit(~/.gitconfig), ...) andblockReadsOutsideWorkingDirectorieskeeps the Read tool inside the clone. The Claude Code process itself (the model call, the login) runs outside the sandbox, which is why the subscription still works. Codex's--sandbox workspace-writeis that vendor's own sandbox; Grok and fx have no OS sandbox, and run under their permission modes alone. - The permission allowlist (
CODE_TOOLS,CODE_DISALLOWED_TOOLS) is the second layer. It limits which tool calls Claude Code accepts without asking (named git and pnpm subcommands; no push, no package runners, no fetchers); the sandbox limits what those commands can reach once they run. Either alone is weaker than both. relay work --no-sandboxis the escape hatch, and it is recorded. The worker logs a warning on start, sayssandbox OFFin its banner line, and registers with the labelsandbox:off, which the workers page shows. Use it only on a machine where the Claude lane's Bash may run with the full rights of the worker's OS user.- The lane still runs as the worker's OS user. The sandbox denies the credential paths above, but any other file
that user can read, a fooled lane can read;
cleanEnvstrips the API keys and the worker'sRELAY_TOKEN,RELAY_SITEandRELAY_HOMEfrom the lane's environment, and the clone under$RELAY_HOME/workis deleted after the job. The next tier is a dedicated OS user or an Applecontainerfor the worker (planned asrelay work --isolation container; not built yet). - A dedicated OS user or a container for
relay workis the recommended setup until that exists. Create a user that holds the vendor logins and theghlogin and nothing else (no SSH keys, no sudo, no browser profile, no access to your home directory) and run the worker service as that user, or run the worker in a container whose only credentials are those logins. A worker on your daily-use account works and is howrelay setupgets you started; with the sandbox on it is acceptable, without it it should be temporary. - Linux prerequisites.
bubblewrapandsocatmust be on PATH (sudo apt install bubblewrap socat, ordnf);relay doctorhas asandboxline that says so. macOS needs nothing extra. - Branch protection on the default branch is required. The worker pushes with the operator's
ghlogin; only the repository's rules keep arelay/<lane>-<issue>branch from becomingmainwithout a review. - Set
RELAY_ALLOWED_SENDERS: it is what keeps a label added by anyone with triage on a linked repo from becoming a job on your machine.
Runner isolation
- One dedicated OS user per machine holds the agent logins and nothing else (
docs/RUNNERS.md). - Runners are registered at the org level in a runner group restricted to private repositories. Public repos never
get the
relaylabels: a fork PR could otherwise run on your machine. - One job per runner process; the subscriptions are personal-use and the lanes are serialized per subscription.
- Every job has
timeout-minutesfrommax_minutes(default 60, reviews 30) and a per-issue concurrency group. - The relay scripts a job runs are cloned at
RELAY_REFfromRELAY_REPOusing the runner's own gh login. PinRELAY_REFto a tag or SHA if you do not want runners to pick upmainautomatically. - Jobs leave nothing behind:
$RUNNER_TEMP/workand$RUNNER_TEMP/relayare removed in analways()step. The brief and the CLI's JSON output are written to amktemp -ddirectory that is removed on exit. - The web app receives only webhooks (verified with
GITHUB_WEBHOOK_SECRET, the GitHub App's own secret, andVERCEL_WEBHOOK_SECRET) and API and MCP calls carrying a scoped bearer token. It can never reach a runner; runners call it, not the reverse. The one place it makes outbound calls to an address a caller chose, outbound webhooks, refuses loopback, link-local, private-network and.internal/.localtargets and requireshttps(validateWebhookUrl; plainhttptolocalhostis for development only), so awebhooks:writetoken cannot aim the site at its own network. - The web app's GitHub credential is an App, not a person's token: installation tokens are minted per repository, last
an hour and live only in memory; the private key sits in
GITHUB_APP_PRIVATE_KEYor, when the manifest flow created the app, sealed in the database underRELAY_SESSION_SECRET(docs/HOSTING.md).
Never allowed
These are refused in code review regardless of who asks or why:
- Running any lane with permission checks disabled:
--dangerously-skip-permissions,--allow-dangerously-skip-permissions,--permission-mode bypassPermissions,fx --full-access/--yolo,codex --dangerously-bypass-approvals-and-sandbox, or equivalents.relay-run.ymlandrun-lane.shcontain none of them and CI greps for them. - Copying or syncing a login between machines, or storing one as a GitHub secret or Vercel variable.
- Expanding
${{ github.event.* }}user content inside arun:script. - Serving a public repository from a self-hosted runner, or removing the sender gate.
- Giving the runner OS user sudo, SSH keys, or access to another user's home directory.
- Sending a transcript, or anything other than the brief, to a model; storing transcripts in memory.
- Letting the lane merge, force-push to a protected branch, or push anything other than its own
relay/<lane>-<issue>branch (--force-with-leaseon that branch only). - Adding a non-official source domain to the onboarding registry without a user allowlist entry.
Reporting
Security issues in relay itself: open a private security advisory on the repository rather than a public issue. Include the workflow run id if a runner was involved; the Actions log never contains credentials, so it is safe to attach.
Signing in to the web app
The web app signs people in with GitHub OAuth, with no third-party auth library. Sign-in is allowed for the GitHub
logins listed in RELAY_ALLOWED_USERS (comma-separated, case-insensitive), the same identities runners trust as issue
senders. An empty list means nobody can sign in, and the sign-in page says so. A successful sign-in mints a signed
session cookie scoped to the site; nothing else is stored. Agents, runners, and bots never use the browser flow: they
hold scoped bearer tokens, issued by the site's own authorization server after one approval by a signed-in person
("Access for agents" below) or minted by the operator with relay token create. There is no self-serve sign-up: every
token traces to a person on the allowlist. Public read of the feed is off in production; the landing, the trust pages,
the docs, the methodology, and the install skill stay public.
Needed in the environment: GITHUB_OAUTH_CLIENT_ID, GITHUB_OAUTH_CLIENT_SECRET, RELAY_SESSION_SECRET,
RELAY_ALLOWED_USERS. docs/HOSTING.md has the OAuth app setup.
The flow
| Route | What it does |
|---|---|
GET /auth/github/start?next=/path |
Stores a random state (and the same-site next path) in a 10-minute HttpOnly cookie and redirects to GitHub's authorize page with scope read:user and allow_signup=false. |
GET /auth/github/callback?code=&state= |
Checks state against the cookie, exchanges the code, reads /user, checks the login against RELAY_ALLOWED_USERS, mints a new session, and redirects to next. Failures redirect to /sign-in?error=<code> (denied, no-allowlist, unconfigured, state, exchange, user, github); the GitHub login is never put in a URL. |
POST /auth/sign-out |
Clears the cookie and redirects to /sign-in. A plain form post, so it works without JavaScript. |
GET /sign-in |
One button, "Continue with GitHub", the allowlist hint, a setup hint when the environment is incomplete, and a note that agents use tokens. A signed-in viewer is redirected to next. |
The GitHub access token is used once, to read /user, and dropped; relay never stores it.
The session
The cookie is relay_session: HttpOnly; Secure; SameSite=Lax; Path=/, 30 days. Its value is a JWT signed with HS256
over RELAY_SESSION_SECRET (jose), issuer relay, carrying {login, name, avatar_url, iat, exp}. Every sign-in
mints a fresh token (new iat and exp); nothing is refreshed and there is no server-side session store, so signing
out is clearing the cookie and revoking everyone is rotating RELAY_SESSION_SECRET. SameSite=Lax means the cookie
is not sent on cross-site POST/PUT, which is what keeps the cookie-authenticated writes below free of CSRF.
Server code reads the viewer through apps/web/lib/session.ts: getSession() (null when anonymous) and
requireSession() (redirects to /sign-in?next=). Writes made in the browser, such as approving or rejecting an
asset, are attributed to human:<login>. A signed-in browser calling /api/v1/* without a bearer gets every scope
under that actor; API clients still use tokens.
Public and protected routes
apps/web/proxy.ts runs on every request and classifies the path with classifyRoute (apps/web/lib/access.ts,
covered by apps/web/test/access.test.ts). Protected pages redirect to /sign-in?next=<path>; protected API paths
answer 401 as application/problem+json with a WWW-Authenticate: Bearer challenge.
| Public without a session | Why |
|---|---|
/ |
the public landing for an anonymous visitor; the feed for a signed-in viewer (apps/web/lib/negotiate.ts decides, the page renders accordingly) |
/info, /about, /contact, /privacy, /docs, /docs/*, /api/markdown |
the public overview, the trust pages and the docs, HTML and Markdown |
/openapi.json, /llms.txt, /llms-full.txt, /.well-known/*, /server.json, /robots.txt, /sitemap.xml, /manifest*, /skills/* |
the agent-readiness surface |
/sign-in, /auth/* |
the sign-in flow itself |
/oauth/* |
the authorization server: register, token, device, revoke are for clients; authorize asks for the session itself and returns to the consent page after sign-in |
/api/v1/info |
the API's own description |
/api/v1/* with an Authorization: Bearer header |
the token's scopes decide (apps/web/lib/auth.ts) |
/api/v1/* GET without a bearer when RELAY_PUBLIC_READ=1 |
anonymous read, limited to the public read scopes (feed:read, memory:read); off by default outside mock mode |
/mcp |
its own bearer rules: anyone can initialize, tools/list and call relay_get_info and relay_get_methodology; the feed and project read tools answer anonymously when RELAY_PUBLIC_READ=1; every other tool needs a bearer with the scope and otherwise returns a tool error naming it |
/api/webhooks/*, /api/cron/* |
authenticated by GITHUB_WEBHOOK_SECRET, VERCEL_WEBHOOK_SECRET, CRON_SECRET |
/api/assets/stream, /api/assets/bundle/*, /api/assets/upload |
the signed URL is the authentication |
/_next/*, /icon.svg, /opengraph-image |
static output |
| Session required | |
|---|---|
/projects, /projects/*, /usage, /workers, /assets, /assets/*, /tokens (the Access page) |
the app pages (and the feed, which is what / shows a signed-in viewer) |
/activate, /setup/* |
approving a device code; the GitHub App setup |
/api/feed, /api/meters, /api/assets/mock/* |
the pages' internal routes |
/api/v1/* without a bearer (and anonymous writes even with RELAY_PUBLIC_READ=1) |
the signed-in viewer's scopes apply |
| anything not listed | neither: Next answers 404 (Markdown when Accept: text/markdown), never a redirect to sign-in, so agents and scanners see a real not-found |
In mock mode (RELAY_DATA=mock, or no DATABASE_URL with RELAY_DATA unset) there is no sign-in: every request
runs as the fake viewer human:demo, the header says "demo mode", and RELAY_REVIEWER may rename that actor. The
fake session never reaches /api/v1: anonymous API callers in mock mode get only what RELAY_PUBLIC_READ allows.
Response headers
apps/web/next.config.ts sets on every response: Referrer-Policy: strict-origin-when-cross-origin,
X-Content-Type-Options: nosniff and Strict-Transport-Security: max-age=63072000; includeSubDomains; and on every
route except the asset viewers under /api/assets/ (which the app frames itself and which carry their own sandbox
CSP): Content-Security-Policy: frame-ancestors 'none' and X-Frame-Options: DENY, so no other site can frame the
app. Writes that a browser makes, the consent decision (POST /oauth/authorize/decision), the /activate form and
every server action on the Access page, are refused when the browser marks the request Sec-Fetch-Site: cross-site
(apps/web/lib/fetch-site.ts), a second line behind the SameSite=Lax cookie. A 500 from /api/v1/* carries a
correlation_id and a fixed detail; the message goes to the server log only.
RELAY_SESSION_SECRET must be at least 32 characters (openssl rand -hex 32 gives 64); shorter values are refused
with that hint when signing a session or sealing a setting, so a weak secret cannot be configured by accident.
Access for agents
Tokens are never copied between people, machines or chats. The web app runs an OAuth 2.1 authorization server
(/.well-known/oauth-authorization-server, RFC 8414; the API and MCP server point at it from
/.well-known/oauth-protected-resource, RFC 9728, and from every WWW-Authenticate challenge) and every grant ends in
one approval by a person who signed in here. The code is packages/core/src/oauth/server.ts, framework-free and
covered by packages/core/test/oauth.test.ts; the routes under apps/web/app/oauth only parse requests.
docs/INTEGRATIONS.md is the how-to.
What a token is
| Kind | Where it lives | Lifetime | Rotation |
|---|---|---|---|
access token (rly_…) |
a row in tokens named oauth:<client name>, SHA-256 only, with expires_at and client_id |
24 hours | replaced on every refresh; the old one is revoked when the new one is minted |
refresh token (rrt_…) |
oauth_refresh_tokens, SHA-256 only, linked to its access token |
90 days | single use: each refresh issues a new pair and marks the old refresh token used; using an old one again revokes the whole lineage (every descendant and their access tokens) and writes access.revoked |
authorization code (rac_…) |
oauth_codes, SHA-256, bound to the client, redirect URI and PKCE challenge |
10 minutes, one use | a second exchange fails and the code stays spent (a wrong code_verifier also spends it) |
device code (rdc_…) |
oauth_device_codes, SHA-256, with the RLAY-XXXX user code |
15 minutes, one redemption | polling faster than interval answers slow_down |
invite code (rin_…) |
invites, SHA-256, with uses_left |
what --expires says (default 24 h), --uses redemptions (default 1) |
spent atomically; revocable by id or name |
operator token (relay token create, "Mint a token by hand" on /tokens) |
the same tokens table, no expiry |
until revoked | none |
Who may approve, and how much
Three kinds of actor decide a device request or mint an invite, and the server tells them apart by the actor string:
| Actor | Where | May grant |
|---|---|---|
a signed-in person (human:<login>) |
the Access page, /activate, the operator's own CLI |
anything, including tokens:write |
a credential with tokens:write (bot:oauth:woodhouse, token:ci, the shared RELAY_MEMORY_TOKEN as token:shared) |
relay_access_approve, relay_invite_create over /mcp; POST /api/v1/access/{code}/approve, POST /api/v1/invites, POST /api/v1/tokens |
only scopes it holds itself, and never tokens:write |
| anything else | nothing: the scope gate refuses before the decision | nothing |
The ceiling is grantCeilingError in packages/core/src/oauth/server.ts, checked by decideDeviceRequest (approve
only; a deny is always allowed), createInvite and the by-hand mint at POST /api/v1/tokens from the scopes the
caller's grant carries, which the API routes and the MCP server pass explicitly. human: actors are only ever
sessions: the shared RELAY_MEMORY_TOKEN is token:shared over /mcp and /api/v1, a credential like any other. A refusal is 403 / an MCP tool error reading "a bot can approve only scopes it holds
itself, and never tokens:write; ask a person to approve this one at <site>/activate" and changes nothing: the code
stays pending for a person. So a chief-of-staff bot lets other agents in without anyone clicking, but an approved bot
can never widen itself, and tokens:write (the scope that makes an approver) is only ever handed out by a person.
Every decision is an access.granted or access.denied row naming the actor.
The Access page (/tokens) shows all of this to the operator: device requests with Approve and Deny (pending,
approved and waiting for the client to poll /oauth/token, and the last day's decisions), every token with how it was
issued (OAuth client, invite, by hand) and whether it renews, invites, registered clients, and the by-hand form folded
away at the bottom. Retention: a daily cron (GET /api/cron/access-expire, CRON_SECRET, 04:30 UTC in
apps/web/vercel.json) runs purgeAccess: device and authorization codes expired for more than a day, refresh tokens
expired for more than 30 days, and orphan clients (registered on the fly by the device flow, older than a day, with no
tokens, refresh tokens or live codes) are deleted; access.* feed rows stay as the audit trail and one access.purged
row records the counts when anything went. Any other client can be removed on the Access page once nothing live
depends on it (removeClient refuses otherwise).
PKCE S256 is mandatory and plain is refused; code_challenge_methods_supported says only S256. Redirect URIs are
registered at POST /oauth/register and must be https, loopback http (any port, RFC 8252) or a private-use scheme;
an authorization request whose client or redirect URI does not match is answered on the page, never by redirecting
to the URI the attacker supplied. A resource indicator must name this site.
Scopes: a request must say what it is for. An authorization request or a device request without scope is
invalid_scope, never "everything"; only POST /oauth/register defaults (to the whole catalogue as the client's
ceiling) and a refresh without scope keeps the grant's own scopes. Scopes may only narrow on refresh.
Client names: the device flow registers a public client per machine from client_name, and an invite redemption may
name its client the same way. That name is read aloud on /activate, becomes the token's name (oauth:<name>) and
appears in feed rows, so it is 1 to 40 characters from letters, digits, space and . _ @ : + -
(CLIENT_NAME_PATTERN); anything else is invalid_request before a client row exists. The access.requested summary
is a fixed sentence, client "<name>" asks for access: approve RLAY-XXXX at <site>/activate; the requested scopes are
in the payload only, so a client cannot write its own feed line. At most 20 device requests may be pending site-wide
(MAX_PENDING_DEVICE_REQUESTS); the 21st is slow_down (429) until some are decided or expire. An approval does not
outlive its code: a device code approved but never collected within its 15 minutes is expired_token at the token
endpoint, and the row refuses redemption past expires_at as well.
What the consent page shows
/oauth/authorize (and /activate for device codes) renders, for the signed-in person: the client's registered name
(relay-cli@studio, Grok Bot), each requested scope with the one-line description from TOKEN_SCOPE_DESCRIPTIONS,
where the browser returns afterwards (the redirect URI's host) and, when present, the resource, plus the two facts
that matter: the token lasts 24 hours and renews itself for 90 days, and it can be revoked on /tokens (Access). Approve and
Deny are plain form posts; the session cookie is SameSite=Lax, so a cross-site form cannot approve on the person's
behalf, and the decision route re-validates the client and redirect URI against the registration before issuing
anything the hidden fields could not have changed. A request that was not started by the person should be denied,
and the page says so.
Approvals without a browser go through POST /api/v1/access/<user_code>/approve with a tokens:write token or the
session; GET /api/v1/access lists what is pending. Giving a chat bot tokens:write is a deliberate choice: it can
then approve devices and mint invites. Every request and decision is a feed row (access.requested, access.granted,
access.denied, access.revoked) with the user code, client and scopes in its payload, so the trail is auditable
and a webhook can relay it.
Revocation
relay token revoke oauth:<client>, the Access page (/tokens), orDELETE /api/v1/tokens/{id}revoke an access token at once; its refresh token keeps working until it is also revoked, so revoke the refresh token too when the holder is not trusted:POST /oauth/revokewithtoken=<refresh or access token>(RFC 7009) does both at once.relay invite revoke <id|name>, Revoke under Invites on the Access page, orDELETE /api/v1/invites/{id}stops an unredeemed code. Revoking by name (an invite's name, orrelay token revoke oauth:<client>) is withintokens:writeby design: a holder of that scope may revoke any token or invite, including every token sharing a client name. Revocation is the lesser power next to minting, and the scope that can mint is the one that should be able to undo it; the audit row (access.revoked,access.invite_revoked) names who did it.- Rotating
RELAY_SESSION_SECRETsigns every browser session out and makes the sealed GitHub App key unreadable; it does not touch tokens, which live in the database by hash. - A reused refresh token revokes its lineage automatically; the holder sees
invalid_grantand must be approved again.
GitHub App
The web app's GitHub credential is an App (GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY, or the sealed settings the
manifest flow on /setup/github wrote; the environment wins). It needs Actions read, Contents read, Deployments read,
Issues write, Metadata read, Pull requests write, and the events issues, issue_comment, pull_request,
pull_request_review, workflow_run, deployment_status. Installation tokens are minted per repository with a nine-minute
RS256 app JWT, cached in memory for their one-hour life and refreshed a minute early. The app's webhook deliveries are
verified with the same HMAC as an organization webhook. installation events write project.github_connected /
project.github_disconnected; a repository is auto-registered as a project only when it already holds a relay.md.
Rotation: a new private key in the environment wins immediately; or remove the stored app on /setup/github and run
the manifest flow again, then revoke the old key on GitHub.
Edge rate limits
Per-IP limits on the device, registration and token endpoints and on anonymous API and MCP traffic are enforced by the Vercel WAF in front of the app (apps/web/infra/firewall.json, applied as in docs/HOSTING.md). They are the shared-store limiter the in-app one is not.