Skip to content
The AI Agent Factory

Field guide: securing connectors, product by product

Approved by
Panaversity ·
124 min read

The exact files, commands and messages that Concept 18.17 summarizes and Lab 18 uses. Checked on each owner's own pages on November 2, 2026. The authors ran every step they could on their own computer, with scripts in place of a person at a browser. Where a step still needs a person online, it says not yet run online. Where it needs a person signing in through a browser, it says not yet run by a person in a browser. Either way, it says what is missing. If a step does not match what you see, check the linked page and note the difference in ports/ch18.md.

Who runs these commands: a coding agent, in a Local session in Manual mode, as Chapter 12 taught, and you approve each one after reading it. The few steps only you can do are marked by you. You make accounts, sign in and approve consent in your browser, and type a test user's password into a sign-in page. You also create the deploy token, and decide when to merge and when to deploy production.

SectionUse it when
F0. Keys, passwords and the connector you start withBefore Task 0
F1. Sign-in from zeroBefore Task 1. The words, and Chapter 17's words mapped to OAuth's
F2. Relational databases and D1 from zeroBefore Task 2
F3. Better Auth from zeroBefore Task 2
F4. The sign-in in detailWhen a task or an error names an exact header, field or claim
F5 to F7. Claude Code, Codex, OpenCodeSitting 4, to sign each AI app in
F8. Deploying the sign-in serverSitting 5
F9. The multi-tenant sign-in checklistTask 12, and every review after
F10. How today's tools realize it, in detailWhen an AI app does something 18.17 does not explain

F0. Keys, passwords and the connector you start with

The connector you start with. The lab zip holds Brightline's connector as Chapter 17 left it. In one page:

  • What it is. A read-only MCP server with four tools: find_vendor, list_open_invoices (five at a time, with a sealed page handle), get_purchase_order and search_policy. It is written twice, in TypeScript (servers/ap-ts/) and Python (py/brightline_ap/), from one contract, docs/connector-spec.md. The Python version's code and tests live in py/brightline_ap/ and py/tests/. servers/ap-py/ holds only its Cloudflare wrapper: the Worker's settings and a short entry file that runs that code as a Worker (written, but not yet run by the authors).
  • Its front door. servers/ap-ts/src/http.ts and py/brightline_ap/mcp_server.py check the Origin header (which website a request came from, Chapter 17's guard against web pages calling the connector). Then they check a shared access key and pass the request on to the tools. This chapter replaces the key check. The zip's npm run keys no longer makes that shared key, so if you start this connector by hand before Task 6, it answers 503, "not ready". No task starts it before then.
  • Its data. Brightline's invented CSV files in data/ and three policies in docs/policies/, packed with the code.
  • Its checks. Unit tests for each version, and scripts/conformance.ts, which starts two copies of each version and proves they give the same answers.
  • Online. servers/ap-ts/wrangler.jsonc defines production and staging, and .github/workflows/deploy.yml deploys staging on every merge to main and production when a person runs it.

Every key in this chapter. "Key" means several different things in this chapter, so here is each one, once.

KeyWhat it isWho holds it
Access keyChapter 17's shared secret. Removed in this chapterNobody, once Tasks 6, 7, 17 and 18 have removed it
Handle keySeals page handles (17.4)Only the connector
Signing key (private)Makes the signature on every tokenOnly the sign-in server
Public keysCheck signatures. Published for anyoneEveryone, including the connector
Admin key (AUTH_ADMIN_KEY)Lets your scripts add and remove companiesYour scripts, and the sign-in server, which checks it
Previous handle key (AP_HANDLE_KEY_PREVIOUS)The old handle key, kept for a short time while a new one takes over, so open handles still work (F8)Only the connector
BETTER_AUTH_SECRETProtects the sign-in server's sessions and encrypts its private signing keysOnly the sign-in server
Test signing key (AP_TEST_SIGNING_KEY)Seals test tokens on your computer. Never deployed (F8)Your tests
CI client secretThe automated checks' password for getting tokens with no person (explained in F1 and F8)GitHub, as a secret
Deploy tokenLets the workflow upload code to Cloudflare. Not a sign-in token (Chapter 17)GitHub, as a secret
Primary key, foreign keyNot secrets at all: columns in a database table (18.8)

Ideas from earlier chapters this lab uses. You need no earlier lab, so here is each, in a sentence.

IdeaWhat it is
Local session, Plan mode, Manual modeWays to run a coding agent in its desktop app (Chapter 12). Local: on your computer. Plan: it proposes and changes nothing. Manual: it asks before every command and every file change, and you approve each
The five-step routineEvery task: a fresh session, a plan, Manual mode, evidence, a commit and one line in ports/ch18.md (Lab 18, "Before you start")
Four-Part BriefA prompt in four labeled parts: Outcome, Context, Format, Constraints (Chapter 5). Used before risky work such as deploying
AGENTS.md, CLAUDE.mdThe repository's written rules for coding agents. They read them at the start of every session
.claude/settings.json, opencode.jsonc rulesSettings that make an agent ask, or refuse, before certain commands, even if a session is left in a looser mode
REVIEW.mdThe repository's checklist for reviewing a change
NOTES.md, ports/ch18.mdYour own notes: predictions and findings, and one line per task saying what was proved, with which agent
Acceptance checks A1 to A8Eight real questions with the answers Ana expects, in docs/acceptance-prompts.md. A1 looks up Hocking Valley Pallets, A5 finds INV-8303 over its purchase order, A6 finds INV-8310 has no purchase order
The conformance suitescripts/conformance.ts: it starts two copies of each version of the connector and checks they all give the same answers. 0 failed means it passed
CIThe automated checks that run on GitHub for every pull request, and the deploy workflow (Chapter 15)
Repository, commit, branch, tag, diffThe project folder with its full history, kept by git (Chapter 15). A commit is one saved change. A branch is a separate line of changes. A tag is a name for one commit, such as ch18. A diff shows exactly what a change adds and removes
Pull request, mergeA proposed change on GitHub that others review before it joins main, the main line. Merging joins it
WranglerCloudflare's command-line tool, which runs Workers on your computer and puts them online (Chapter 17)
BindingA named link from a Worker to something Cloudflare runs for it, such as a database. The Worker's code reaches it through env, the Worker's list of named connections
LogThe record a server writes as it works, one line per request
SDKSoftware development kit: a code library for building something. The lab uses the official MCP SDKs, one for TypeScript and one for Python (Chapter 17)
Agent Plugins coreThe shared part of Chapter 16's plugin format, which every AI app reads. Its mcp.json lists the plugin's MCP servers
AI reviewA coding agent, in a fresh session that has not seen the build, reads a pull request against REVIEW.md and reports what it finds, before a person reviews it (Chapter 17)
The 503 ruleIf a required setting is missing, the connector answers 503, "not ready", and runs no tool (Chapter 17)

What npm run keys makes now. scripts/make-keys.ts still makes every secret into files git ignores, prints only file names, never overwrites a file, and refuses to write anywhere git would track. In Chapter 18 it no longer makes AP_MCP_TOKEN, the shared key. It makes:

SecretFileWho uses it
AP_HANDLE_KEYservers/ap-ts/.dev.vars, servers/ap-py/.dev.varsThe connector, to seal page handles
BETTER_AUTH_SECRETservers/auth/.dev.varsThe sign-in server, to protect sessions and encrypt its private signing keys
AUTH_ADMIN_KEYservers/auth/.dev.vars, .secrets/admin.local.envThe tenant scripts, to call the sign-in server's admin routes
AP_TEST_SIGNING_KEY.secrets/test-signing.local.envTests only, to sign test tokens. Never deployed (F8)
Test users' passwordsMade later, by npm run tenant:add (Task 8), into .secrets/test-users.<environment>.envYou, typed into a browser sign-in page

The two connector .dev.vars files also get three local settings, which are not secrets: AP_ISSUER, AP_RESOURCE and AP_TRUST_TEST_ISSUER=1. So npm start works at once.

How the test key reaches the connector. AGENTS.md lets code read no key file. So the npm scripts pass .secrets/test-signing.local.env to the program when it starts. npm start and npm test in servers/ap-ts (from the top folder, npm run test:servers) and the root npm run conformance do this with Node's --env-file-if-exists. npm run test:py does it with uv run --env-file, only when the file exists. The root npm test runs only the data-loading tests in tests/ and does not load the file. Under wrangler dev the test key is absent, so the connector trusts only the real sign-in server. A test token names the same issuer as a real one. The connector tells it apart by its kid, the short ID naming the key that sealed a token (18.15). A test token's kid is the test key's thumbprint, a short fingerprint computed from the key itself (RFC 7638).

With -- --deploy, it also makes each environment's own copies. The connector's go in .secrets/staging.env and .secrets/production.env. The sign-in server's two secrets go in .secrets/auth.staging.env and .secrets/auth.production.env. The admin key again goes in .secrets/admin.staging.env and .secrets/admin.production.env. Each Worker gets only its own file, because sending secrets sends a whole file. The connector's files now hold only AP_HANDLE_KEY, and --deploy no longer writes .secrets/github.env. So npm run conformance:staging and :production need the CI client's ID and secret (CONFORMANCE_CLIENT_ID and CONFORMANCE_CLIENT_SECRET, read from .secrets/ci.<environment>.env, F8), and stop with that message otherwise. GitHub keeps the same two with the environment added to the name, such as CONFORMANCE_CLIENT_ID_STAGING, from .secrets/github.env. (The CI client's secret is made later, by npm run client:ci, F8.) Every value is new. No Lab 17 key is reused.

A test user's password is typed only into a browser. When a task says "sign in as Ana", open .secrets/test-users.local.env in a plain text editor (TextEdit or Notepad, never a word processor). Find the line that starts with TEST_USER_PASSWORD_ANA_BRIGHTLINE_EXAMPLE=. Each person's line is named after their email address. Copy the value into the sign-in page in your browser. Never paste it into a chat. Files whose names start with a dot are hidden: on a Mac, press Cmd+Shift+. (period) in Finder. On Windows, choose View, Show, Hidden items in File Explorer.

No key in a terminal this time. In Chapter 17 you set AP_MCP_TOKEN in a terminal so your AI apps could send it. That step is gone. AI apps now sign in through your browser and keep their own tokens. If an old terminal still has AP_MCP_TOKEN set, close it.

F1. Sign-in from zero

What sign-in is for, in one paragraph. Every system that holds something private must answer two questions about each request: who is this, and what may they do? For a person at a website, the answer has long been a password. For a program acting for a person, such as an AI app, the person should not hand over their password. So the program gets a token instead: a short-lived pass that says who the person is and what the program may do for them. OAuth is the open standard for getting that token safely. Everything in this chapter is a detail of that one idea.

The words.

WordPlain meaning
HashA one-way scramble. The same input always gives the same output, and the output cannot be turned back into the input. Passwords are stored only as hashes
SessionA short-lived proof that you signed in to one website, so you do not type your password again on every page
CookieA small value a website asks your browser to keep and send back on each visit to that site. Sessions live in cookies
Return addressWhere the browser is sent back to after sign-in. The standards call it the redirect address (redirect_uri)
Go-elsewhere replyA reply that sends the browser, or a program, on to another address. The standards call it a redirect
ConsentThe screen where you approve what an app may do for you, and in Brightline's case, pick the company
TokenA value that proves something to whoever checks it. A sign-in token proves who you are, for which company and what you may do
ClaimOne fact inside a token, such as tenant: org_brightline
SignatureA value made with a private key that proves the token was made by its issuer and not changed. Anyone with the matching public key can check it

Words you will see in output. Agents and AI apps show these. You need to recognize them, not use them.

WordMeaning
Bearer"Whoever carries this token may use it." Written before the token in the Authorization header
EndpointOne address on a server that does one job, such as /api/auth/jwks, or the sign-in server's token endpoint, where codes are swapped for tokens. Also called a route
stateA random value the app sends with the sign-in and checks when the browser comes back, so it knows the reply belongs to the sign-in it started
GrantA way of getting a token. The lab uses three: a code (18.2), a refresh token (18.6), and client credentials (below)
Client secretA password that belongs to an app itself, not to a person. Apps on your own computer cannot keep one safe, so coding agents sign in without one. Only the automated checks' app has one (F8)
Client credentials grantGetting a token with no person at all, using an app's own client ID and client secret. Used only by the automated checks (F8)
invalid_grantThe sign-in server's answer when a code or refresh token cannot be used: expired, already used, or the person was removed
offline_accessA scope that asks for a refresh token. An app that does not ask for it gets none. Connectors should not ask for it themselves. The AI app decides
iat, azpToken claims: when it was made, and which app it was given to
S256The name of the one-way scramble PKCE uses (18.4)
LoopbackAn address that means "this computer", such as 127.0.0.1. localhost means the same computer, but in a return address the two do not match each other (F4, step 4)
POSTThe kind of web request that sends information, like submitting a form (Chapter 17)
DecodedA token turned back into readable labels and values. Showing a decoded token's claims is safe. Showing the token itself is not
noneIn a sign-in server's document, it means "apps with no client secret are welcome", which coding agents need
EncryptScramble so that someone with the right secret can unscramble it. Unlike a hash, it can be turned back
TypesA program library's own description of its settings, which an agent can read
RuntimeThe place that runs a program: a Worker, or Node on an ordinary server

Chapter 17's words mapped to OAuth's.

Chapter 17 saidOAuth saysNotes
Sign-in tokenAccess tokenThis book keeps "sign-in token"
AI appClientThe whole AI app
Connector, serverResource serverIt only checks tokens
(none)Authorization serverThe sign-in server, new in this chapter
Access key(none)Removed in this chapter

Why the browser? The browser is the one place where you can see which website you are typing your password into, check its address, and use your password manager. An AI app that asked for your password directly would see it, and so could anything that compromised the app. That is why every AI app in this chapter opens your browser to sign in.

F2. Relational databases and D1 from zero

Meet D1. Before the details, what it is and why the lab uses it.

  • What it is. D1 is a database service run by Cloudflare, the company whose Workers ran Brightline's connector in Chapter 17. You ask for a database with one command, and Cloudflare stores it, keeps copies, and lets your Workers read and write it. You never install or look after database software yourself.
  • Why a database at all. The sign-in server must remember people, companies, memberships and approvals, and change them while it runs. Files packed with the code, as in Chapter 17, cannot change. A database can, safely, even when many copies of the sign-in server write at the same moment (18.8).
  • Why D1 for this lab. It sits next to the Workers you already use, it needs no separate account, and its free plan is far larger than Brightline's sign-in server needs. Any other relational database would work. Chapter 19 uses a different one, Postgres, for business data.
  • What it costs. Nothing for this lab, on the free plan (limits at the end of this section).

The ideas. Concept 18.8 teaches tables, rows, columns, primary and foreign keys, join tables and the three query shapes, with the sign-in server's own user, organization and member tables. Read it first.

The sign-in server's tables. Better Auth creates them. The ones you will read:

TableOne row perPoints at
userperson
accountway a person signs in. Holds the password hashuser
sessionsigned-in browseruser
organizationcompany (tenant)
membermembership, with roleuser, organization
verificationshort-lived check, such as a reset link
invitationinvitation to join a companyorganization, user
oauthClientregistered AI appuser (userId, the person who registered it, if any)
oauthResourceconnector address tokens can be made for
oauthClientResourcewhich connectors a registered app may get tokens foroauthClient, oauthResource
oauthConsentapproval: person, app, company, scopesuser, oauthClient
oauthAccessTokenrecord of a sign-in tokenuser, oauthClient, session, oauthRefreshToken
oauthRefreshTokenrefresh token, stored as a hashuser, oauthClient, session
oauthClientAssertionsigned proof an app sent, kept so it cannot be used twice
jwkssigning key pair, private half encrypted

In oauthConsent and oauthRefreshToken, the company is kept in a plain text column, referenceId, which holds the company's id but does not point at organization as a foreign key. The lab adds one column of its own, organization.tokensStoppedAt (F3). These are the names Better Auth 1.7.15 creates. Your agent lists them, with their columns, in Task 2.

Why the sign-in tables must be in third normal form. About five minutes. Read 18.8 first.

A normal form is a rule for laying out tables so that each fact is stored in exactly one place. There are three basic ones, and each builds on the one before. A database that follows all three is "in third normal form", or 3NF. In a sign-in database this matters more than anywhere else. A fact stored twice can drift, and here a fact that drifts decides who may act for which company.

Here are the four tables that decide that, cut down to the columns this section needs. The real ones have more columns (the table above), but the same shape.

-- Simplified. One fact, one place.
CREATE TABLE user (
  id    TEXT PRIMARY KEY,
  email TEXT NOT NULL UNIQUE
);

CREATE TABLE organization (
  id   TEXT PRIMARY KEY,
  name TEXT NOT NULL              -- the company's name lives here, and only here
);

CREATE TABLE member (
  userId         TEXT NOT NULL REFERENCES user(id),
  organizationId TEXT NOT NULL REFERENCES organization(id),
  role           TEXT NOT NULL,
  PRIMARY KEY (userId, organizationId)  -- one row per person per company
);

CREATE TABLE session (
  id                   TEXT PRIMARY KEY,
  userId               TEXT NOT NULL REFERENCES user(id),
  activeOrganizationId TEXT       -- the company picked at sign-in
);

member has a composite primary key: two columns that together name one row. Neither the person alone nor the company alone is enough. The three rules, in Brightline's terms:

  • First normal form: one value per cell. Every column holds a single value. Priya works for two companies, so she has two rows in member, never one row saying "Brightline, Cedar Ridge".
  • Second normal form: every fact depends on the whole key. A role belongs to the pair, the person and the company. Priya's role in Brightline and her role in Cedar Ridge are two separate facts, and they can differ. So role sits in member, keyed by both columns.
  • Third normal form: no fact depends on another non-key column. A company's name depends on the company, not on the membership. So the name lives only in organization, and member holds just the company's id. To show a name, a query joins the two tables (18.8, query 2).

The mistake this prevents. Suppose a builder "saves a join" by copying the company's name into every membership:

member_bad, the mistake

userIdorganizationIdorganizationName
u_anaorg_brightlineBrightline
u_graceorg_brightlineBrightline
u_priyaorg_brightlineBrightline Supply Co.

Brightline renamed itself, and the change reached one row but not the others. Now one company has two names. Ana's company picker shows one name and Priya's shows another, and nobody can tell which is right. This is an update anomaly: a change that has to be made in many places and is bound to miss one. In the 3NF layout, a rename changes one row in organization, and every picker shows the new name at once.

Two honest notes about the real tables.

  • In Better Auth's real member table, each membership has its own id column as the primary key. The database itself does not refuse a second row for the same person and company. The lab's admin route checks first and refuses with 409 ... is already in org_brightline. The composite key above would make the database refuse it too.
  • The company that goes into a token is also stored as plain text with no foreign key: referenceId on oauthConsent and oauthRefreshToken, and session.activeOrganizationId. Each is a copy of a company's id, not its name. It is safe because membership is checked again from member on every token (18.10). A few tables, such as oauthClient, also keep short lists in one text column (an app's return addresses, its scopes).

Takeaway. Auth DB = normalize hard. Easy to understand on D1. Each table is small, and one read-only query shows you every row.

Chapter 19 keeps Brightline's business data in Postgres, run by a service called Neon, and searches policies by meaning, a method called RAG (retrieval-augmented generation), with a Postgres add-on called pgvector.

Neon + pgvector RAG will be covered in next chapter where we will intentionally break 3NF for speed.

How a Worker reaches its database. Each D1 database is a SQLite database, a small kind of relational database also used inside many phones and apps. A Worker reaches it through a binding, Chapter 17's word for a named link from a Worker to a platform resource, in the Worker's env. The binding is set in wrangler.jsonc:

"d1_databases": [
  { "binding": "DB", "database_name": "brightline-auth", "database_id": "<made by Wrangler>",
    "migrations_dir": "migrations" }
]

In plain words: inside the Worker, env.DB is the database called brightline-auth, and its migrations are the files in migrations/. Staging needs its own lines here, because a setting in production's section does not carry over to staging. (A .jsonc file is JSON that may contain notes for people.)

Local and remote. On your computer, Wrangler keeps a local copy of each database in a folder under .wrangler/. Online, the remote database lives at Cloudflare. Every D1 command takes --local or --remote, and the lab's scripts always say which, because a command aimed at the wrong one is the classic mistake.

How an agent shows you rows, read-only. Ask in plain words. The agent runs, for example:

npx wrangler d1 execute brightline-auth --local --command "SELECT id, name FROM organization;"

From Task 18 on, .claude/settings.json and opencode.jsonc make every wrangler d1 execute and wrangler d1 migrations apply ask first, and deny wrangler d1 execute with --remote against production, so nobody changes production rows by hand. Before that, Manual mode makes the agent ask before every command anyway. Changes go through the tenant scripts (Task 8) or migrations.

Migrations. Tables must exist before anything is stored, and they change as the software grows. A migration is one numbered step that creates or changes tables, kept as a file in the repository and reviewed like code. Each database records which migrations it has applied, and applies the rest in order, so staging and production get the same tables by the same steps. A migration that removes a column removes its data, so it gets the same care as a deploy. Migrations change tables, not people: test rows and companies go in through the tenant scripts, never through a migration.

Better Auth's command-line tool writes the SQL that creates its tables (npx auth@1.7.15 generate, F3), and Wrangler applies it as a numbered migration (wrangler d1 migrations apply brightline-auth --local, later --remote per environment). Wrangler records what it applied in each database, and takes a backup before applying remotely.

Free plan. It gives 10 databases, 500 MB each and 5 GB in all. It also gives 5 million rows read and 100,000 rows written a day, reset at midnight UTC, and 50 queries per Worker request. The lab uses two databases (staging and production), a few hundred rows, and a few dozen queries a day.

F3. Better Auth from zero

Meet Better Auth. Before the details, what it is and why the lab uses it.

  • The problem it solves. A sign-in server has a lot to get right: storing password hashes, sessions, consent screens, issuing and sealing tokens, and publishing public keys. It must also replace refresh tokens on every use, and follow the MCP authorization specification exactly. Writing all that yourself is slow and easy to get wrong.
  • What it is. Better Auth is an open-source sign-in library for TypeScript: ready-made code that does those jobs. A library is code you add to your own program, not a service with its own website and account. You add it to your sign-in server program, switch on the parts you need, and give it a database to keep its records in. You can read every line it runs. The lab pins version 1.7.15, the newest stable release on November 2, 2026.
  • Not a service you sign up for. Some companies run sign-in for you on their own computers, for a monthly fee. Better Auth is different: Brightline's sign-in server is your Worker, with Better Auth inside it, and its records in your D1 database. Nobody else holds your people's password hashes.
  • Why this lab uses it. It is free and open source, it runs on Workers, and it already supports the MCP authorization specification and companies with roles. F10 names two alternatives.

The auth instance and its plugins. One file, servers/auth/src/auth.ts, makes the auth instance: Better Auth's settings, the database, and its plugins, which here means Better Auth's own add-ons. Each plugin is switched on with one line, and its settings are named options. You will never type these setting names. Recognize them in a diff. The lab's instance, in plain words:

  • Email and password, for sign-in.
  • jwt(), which makes signing keys, signs tokens, and publishes the public keys at /api/auth/jwks.
  • organization(), which adds companies, memberships and the roles owner, admin and member, plus Brightline's own role, auditor.
  • mcp(), the plugin that makes it a sign-in server for MCP. What it does, in plain words:
    • Turns the Worker into a sign-in server for the two connector addresses, with Brightline's scopes, 10-minute sign-in tokens, a sign-in page and a consent page. The addresses are read from the MCP_RESOURCE (TypeScript) and MCP_RESOURCE_PY (Python) settings and passed as resources and clientRegistrationDefaultResources, so each environment sets its own.
    • Replaces refresh tokens on every use, with no grace period (refreshTokenReuseInterval: 0).
    • Shows the lab's company picker after sign-in (setting name: postLogin.page, set to /consent). The picker lists the person's memberships and saves the pick as the session's active company, through the organization() plugin's set-active call. That call itself refuses a company the person is not a member of.
    • Remembers that company with the consent (setting name: postLogin.consentReferenceId). This setting receives only the person, the session and the scopes, not the consent form, which is why the pick must live on the session. It checks the membership again and returns the session's active company. Better Auth stores it as referenceId on the consent, the one-time code and the refresh token.
    • Writes that company into each token as tenant (setting name: customAccessTokenClaims). It runs for every sign-in token, at the code swap, at every refresh and for client credentials. The lab's version checks the membership, the role's scopes and whether the company still takes tokens, and refuses with invalid_grant if any fails. This is the check that makes a removed person's next refresh fail. Better Auth itself does not check membership at refresh.
    • It cannot change scope, which Better Auth reserves. So the role's scopes are applied on the consent page, which may approve fewer scopes than were asked for, and enforced in customAccessTokenClaims by refusing.
    • The authors' build confirmed that both settings pass through mcp() in 1.7.15: every option except resource goes on to Better Auth's OAuth provider unchanged.
  • cimd(), which accepts Client ID Metadata Documents, with the profile mcp-2026-07-28.
  • One extra column, organization.tokensStoppedAt, added through organization()'s additionalFields (with input: false, so no form can set it) and a second migration. Offboarding (18.14) writes the time into it. The picker, consentReferenceId and customAccessTokenClaims each refuse a company that has it.

Signed tokens, in Better Auth terms. The three steps from 18.6, with the names you will see:

  1. Better Auth's jwt() plugin signs each sign-in token with its private key, an Ed25519 key (algorithm EdDSA). The key lives in the jwks table, and Better Auth keeps its private half encrypted by default.
  2. The connector fetches the public key from /api/auth/jwks and checks the signature (F4, step 9). The authors' build checked that this address shows only public halves: in each key, the private part, d, is absent.
  3. The connector then checks exp, aud, iss, scope and tenant, as in 18.6.

The AI app receives only the token. Better Auth's private key never leaves the sign-in server.

Make the auth instance on every request. On Workers, build the auth instance inside the request, not once for the whole Worker. In the authors' build, a cached instance made every later request hang. One consequence: cimd()'s in-memory cache and fetch budgets last only one request, so an app's document is fetched again on every sign-in.

Dynamic client registration (18.5) stays off. The lab never turns it on.

Fetching an address a stranger chose. In step 2 of 18.5, the sign-in server fetches whatever address the app gave. A stranger could give an address inside Brightline's own private network: the company's internal computers, which the internet cannot reach but the sign-in server can. An internal admin page would then be visited with the sign-in server's own access. This attack is called SSRF, server-side request forgery: tricking a server into fetching an address inside its own private network that the attacker cannot reach directly. The internet turns a name such as app.example into the number of an actual computer, and that answer can change from one moment to the next. So a careful sign-in server follows three rules:

  1. Never fetch a private address. Refuse any address in the ranges set aside for private networks, "this computer", and other special uses.
  2. Check the actual computer it connects to, not just the name. Check the computer's number, and connect to that same number. Looking the name up twice lets an attacker switch it to point somewhere else between the check and the fetch.
  3. Never follow a "go elsewhere" reply, because the new address might be private.

The fetcher for Client ID Metadata Documents. cimd() needs a function that fetches an app's document by these rules. Better Auth ships one only for Node, another place JavaScript programs run (JavaScript is the language TypeScript is built on). Better Auth says other places that run code, Workers included, must supply their own. On a Worker, the fetcher cannot fully guarantee rule 2, so the lab adds an approved list. The lab's fetcher, written by Claude Code in Task 5, does what a Worker can: https only, no redirects, a 5-second limit and a small size limit. Through isMetadataDocumentUrlAllowed, it also takes documents only from an allowlist, a fixed list of known hosts. It starts with one host, chatgpt.com, where OpenAI publishes the documents for its coding agent, Codex. The lab pre-registers Claude Code and OpenCode, so they never meet the list. To try an app's own document later, add its maker's host in a reviewed change. Say this plainly in reviews: an allowlist is stricter, not equivalent.

Three details the authors' build found. Better Auth calls the fetcher with redirect: "error", which a Worker's fetch rejects with TypeError: Invalid redirect value, so a plain passthrough fails every document. The lab's fetcher uses redirect: "manual" and returns any "go elsewhere" reply as it is, and Better Auth then refuses it (Metadata document fetch returned HTTP 302). Better Auth already applies its own 5-second limit and 5 KB size limit, and the lab's fetcher repeats both. A client_id that is not https is never treated as a document (client not found), and a host off the list is refused before any fetch (client_id URL is not permitted by the server's fetch policy).

The consent page's form-action. A real browser found one more. A page's Content-Security-Policy can limit where its forms may send the browser, with form-action. Browsers built on Chromium, such as Chrome and Edge, also apply that limit to the go-elsewhere reply that follows the form. So form-action 'self' blocks the trip back to the AI app, and choosing Allow seems to do nothing. The browser's console names it: violates the following Content Security Policy directive: "form-action 'self'". Scripted sign-ins never see this, because a script is not a browser. The lab's pages allow 'self', http://127.0.0.1:*, http://localhost:* and https:.

Its routes. Under /api/auth:

RouteWhat it is
/.well-known/oauth-authorization-server/api/authThe sign-in server's document (18.3). It also answers at /api/auth/.well-known/oauth-authorization-server. The root address, /.well-known/oauth-authorization-server, gives 404
/.well-known/oauth-protected-resource/mcpA copy of the TypeScript connector's document, which mcp() serves. Each connector still serves its own
/api/auth/jwksThe public keys
/api/auth/oauth2/authorizeWhere the browser is sent to sign in and consent
/api/auth/oauth2/tokenWhere the app swaps a code, or a refresh token, for tokens
/sign-in, /consentThe lab's own two pages, with the company picker on the second
/admin/*The lab's admin routes for the tenant scripts, refused without AUTH_ADMIN_KEY

Better Auth serves the /.well-known/ documents itself, through auth.handler, so the Worker only has to pass /.well-known/* and /api/auth/* to it.

Its admin calls. Only the tenant scripts and the CI setup call the lab's /admin/* routes, with AUTH_ADMIN_KEY. In 1.7.15, Better Auth's own server calls for creating an app client, changing a role and removing a member need a signed-in session. In that version, creating an app client cannot take a chosen client_id, and creating a company cannot take a fixed id. So the admin routes write those rows through Better Auth's database adapter, with forceAllowId for the company's fixed id. A pre-registered client also needs a row in oauthClientResource for each connector, both here, because Better Auth limits each client to its listed connectors by default (enforcePerClientResources). The route that adds a member answers person: "created" or "existing", so tenant:add makes and saves a password only for a new person. Removing a member or changing a role through these routes also deletes that person's consent records and refresh tokens for the company.

Its command-line tool. npx auth@1.7.15 generate writes the SQL for the tables the plugins need. It cannot read a D1 binding: pointed at src/auth.ts, it prints Only kysely adapter is supported for migrations. So the lab keeps a second file, src/auth.cli.ts, used only by this tool, which builds the same auth instance on an empty in-memory node:sqlite database. The command is npx auth@1.7.15 generate --config src/auth.cli.ts --yes --output <file>, and the SQL then goes into a file made by wrangler d1 migrations create brightline-auth <name>. The tool also prints Expected string[] but got TEXT warnings, which are harmless.

After adding a plugin or a column, an empty database would make the tool write every table again. To get only the change, run it against a database with the earlier migrations already applied (the build's npm run generate:next), and turn the result into a new migration.

Going further: sign in with your company (SSO). Not built or run in this lab.

SSO means single sign-on. It lets people sign in to your app with the login their company already gives them, such as Microsoft Entra ID or Okta, instead of making a separate password for your app. The company's login service is called an identity provider. In Better Auth, SSO is a plugin, @better-auth/sso (version 1.7.15, like the rest of the lab). It speaks two standards that identity providers use: OpenID Connect (OIDC), which is built on OAuth, and SAML 2.0, an older standard many large companies still use.

Suppose Cedar Ridge Supply wants its staff to sign in to Brightline's sign-in server with their Cedar Ridge accounts:

  1. A Cedar Ridge employee clicks Sign in with your company.
  2. The sign-in server sends them to Cedar Ridge's login service.
  3. Cedar Ridge checks who they are, including any multifactor authentication: a second proof, such as a code on their phone.
  4. They come back to Brightline's sign-in server, signed in. If they are already signed in with their company that day, they may not need to type anything again.

The important difference: SSO proves who the person is. Brightline still decides what that person may do.

SSO and the connector's sign-in work together. They handle different parts of the same flow:

PartIts job
Better Auth's SSO pluginSigns the person in with their company account
Better Auth's MCP plugin, mcp()Makes the sign-in token that lets the AI app act for that person (18.6)
The connectorChecks the token, and enforces the scopes for each tool and the company (18.7, 18.11)

So a Cedar Ridge employee connecting Codex would go through the same steps as in 18.2:

  1. Codex connects to the connector and gets a 401 (18.3).
  2. Codex starts a sign-in with PKCE (18.4), opening the browser.
  3. Brightline's sign-in page offers Sign in with your company, which is the SSO step above.
  4. The person picks the company (18.10) and approves the permissions asked for, such as reading AP data (18.7).
  5. The sign-in server makes a token for the connector's address only (18.6).
  6. Codex sends that token with each call. The connector checks it, its scopes and its company, as in Tasks 6 to 9.

Never forward the company's own token to the connector. Cedar Ridge's login service gives the sign-in server its own proof that the person signed in. That proof was made for the sign-in server, not for the connector, so passing it on would break the audience rule from 18.6. The sign-in server always makes a separate token, made for the connector's address.

Two things to decide before you add it.

  • Who becomes a member, and with which role. When an identity provider is linked to a company, the SSO plugin adds each person who signs in through it to that company automatically. It gives them a role you set (defaultRole, or a getRole function). Pick member or auditor, never owner, and keep the membership check in customAccessTokenClaims (Task 5) as it is.
  • One provider of sign-in only. mcp() already contains Better Auth's OAuth provider, so do not also add oauthProvider() to the same auth instance. Add sso() beside mcp(), jwt() and organization().

F4. The sign-in in detail

Optional. Read it only when an error or a task names a header, field or claim, except its last part, How the connector does it, which Sitting 2 uses.

For the reader who wants the wire. Addresses are the lab's local ones: the connector at http://127.0.0.1:8717/mcp, the sign-in server at http://127.0.0.1:8719. The field names are the standards'. The sign-in server's messages (steps 3, 7, 8 and 11) are the ones the authors' build of Task 5 produced on November 2, 2026, shortened. Step 4 is an app's own document, shown as an example. The connector's own messages (steps 1, 2, 9 and 10) are the ones the authors' build of Tasks 6 and 7 produced the same day, from both versions of the connector. Compare everything with what Task 1 shows you, and note any difference in ports/ch18.md.

1. The 401. A request with no token:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Sign in to use the Brightline AP connector.", scope="ap:read", resource_metadata="http://127.0.0.1:8717/.well-known/oauth-protected-resource/mcp"

The SDK (software development kit) is the official MCP code library the connector is built with (How the connector does it, below). It writes error and error_description first, even when there is no token. The wording is docs/auth-spec.md's, and scope names ap:read only, because that is what every call needs (How the connector does it, below).

2. Protected resource metadata (RFC 9728), from the connector:

{ "resource": "http://127.0.0.1:8717/mcp",
  "authorization_servers": ["http://127.0.0.1:8719/api/auth"],
  "scopes_supported": ["ap:read", "ap:vendors.read"],
  "resource_name": "Brightline AP connector" }

That is the TypeScript connector's document as served. The Python SDK's also has "bearer_methods_supported": ["header"], an optional field. scopes_supported holds no offline_access, as the specification asks.

3. Authorization server metadata (RFC 8414), from the issuer's well-known address. The fields that matter:

{ "issuer": "http://127.0.0.1:8719/api/auth",
  "authorization_endpoint": "http://127.0.0.1:8719/api/auth/oauth2/authorize",
  "token_endpoint": "http://127.0.0.1:8719/api/auth/oauth2/token",
  "jwks_uri": "http://127.0.0.1:8719/api/auth/jwks",
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post", "private_key_jwt"],
  "scopes_supported": ["ap:read", "ap:vendors.read", "offline_access"],
  "client_id_metadata_document_supported": true,
  "authorization_response_iss_parameter_supported": true }

The real document also lists addresses for checking and canceling tokens (introspection_endpoint, revocation_endpoint), and fields for DPoP and for signing out across apps, which the lab does not use. It has no registration_endpoint, because dynamic client registration is off. offline_access appears here, in the sign-in server's document, and not in the connector's.

The issuer must equal the address the app built the well-known address from, exactly, or the app must not use the document. With no code_challenge_methods_supported, an MCP app must refuse to continue.

4. A Client ID Metadata Document, as an app publishes it:

{ "client_id": "https://app.example/oauth/client.json",
  "client_name": "Example AI App",
  "redirect_uris": ["http://127.0.0.1/callback"],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"] }

client_id must equal the document's own address. The redirect in the request must match one listed, with any port on a loopback address (RFC 8252, section 7.3). Only the port may differ. With http://127.0.0.1/callback listed, the authors' build accepted ports 1, 43123 and 65535, and refused http://localhost:43123/callback, http://[::1]:43123/callback, a trailing slash and an added ?x=1, with invalid_redirect. The same holds for a pre-registered client.

5. The authorization request, the browser's first stop: client_id, redirect_uri, response_type=code, scope, state, code_challenge with code_challenge_method=S256 (PKCE), and resource=http://127.0.0.1:8717/mcp (RFC 8707). The app records the issuer it expects.

6. The response, back to the app's loopback address: code, state and iss. The app compares iss with the issuer it recorded, as a plain string, before anything else. If the sign-in server advertised iss support and iss is missing, the app rejects the response.

7. The token request, a POST to the token endpoint: grant_type=authorization_code, code, redirect_uri, code_verifier (the PKCE secret) and resource again. The reply holds access_token, token_type: "Bearer", expires_in: 600 and scope, and, if the app asked for offline_access, refresh_token. An app that asks only for the 401's ap:read gets no refresh token.

8. A sign-in token's body, decoded (the lab never prints a real token, only its decoded claims). Its label section is { "typ": "at+jwt", "alg": "EdDSA", "kid": "..." }.

{ "tenant": "org_brightline",
  "sub": "<Ana's user id, a random string>",
  "aud": "http://127.0.0.1:8717/mcp",
  "client_id": "https://app.example/oauth/client.json",
  "azp": "https://app.example/oauth/client.json",
  "scope": "ap:read ap:vendors.read offline_access",
  "sid": "<the sign-in session's id>",
  "iss": "http://127.0.0.1:8719/api/auth",
  "iat": 1795500000, "exp": 1795500600,
  "jti": "<the token's own id>" }

aud is a single string. sub is the random id Better Auth gave Ana when she was created, not a name you chose. scope includes offline_access only when the app asked for it.

A CI client's token (F8), made by client credentials, has no sid, and its sub is the client, not a person:

{ "tenant": "org_conformance",
  "sub": "<the CI client, a random string>",
  "aud": "http://127.0.0.1:8717/mcp",
  "client_id": "ci_...", "azp": "ci_...",
  "scope": "ap:read ap:vendors.read",
  "iss": "http://127.0.0.1:8719/api/auth",
  "iat": 1793609965, "exp": 1793610565,
  "jti": "<the token's own id>" }

9. Every MCP request carries Authorization: Bearer <token>, never in the address. The connector checks, in order: the Origin (Chapter 17), the signature against the public keys, iss, aud, exp, the tenant claim's presence, then the tool's scope.

10. The 403 for a missing scope:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", error_description="find_vendor needs ap:read ap:vendors.read. Your role in Brightline does not include it.", scope="ap:read ap:vendors.read", resource_metadata="http://127.0.0.1:8717/.well-known/oauth-protected-resource/mcp"

Every call needs ap:read, and find_vendor also needs ap:vendors.read, so the scope lists both: every scope this call needs. The app adds them to what it already has and asks again.

11. Refresh, only for an app that asked for offline_access. grant_type=refresh_token, refresh_token, resource. The reply has a new sign-in token and a new refresh token. The old refresh token is now retired. Using it again gets invalid_grant, and cancels every refresh token that app holds for the person, for every company, not only this chain. The next refresh with the replacement then gets invalid_grant with session not found.

The standards and their versions. OAuth 2.1 is an IETF draft. The MCP specification cites draft 13, and the current draft on November 2, 2026, is 17. Client ID Metadata Documents are an IETF draft too: MCP cites version 00, and the current one is 02. The others are published RFCs: 6750 (sending a token in a header), 7517 (public key sets), 7519 (JWT) and 7636 (PKCE). Next come 8252 (apps on your own computer), 8414 (the sign-in server's document) and 8707 (naming the connector). The last are 9207 (the issuer check), 9449 (DPoP) and 9728 (the connector's document). Where drafts and the MCP specification differ, this book follows the MCP specification.

How the connector does it. The official MCP TypeScript SDK, version 2.4.0, has helpers for exactly this front door, and they work with a plain fetch function:

  • oauthMetadataResponse serves the connector's document. It requires oauthMetadata, a whole sign-in server document, and would also serve that at the connector's own /.well-known/oauth-authorization-server. A connector should not publish a copy of the sign-in server's document (18.3). So pass only the sign-in server's issuer as oauthMetadata, which is all the connector's document uses, with scopesSupported: ["ap:read", "ap:vendors.read"], and answer /.well-known/oauth-authorization-server with 404 before it.
  • requireBearerAuth reads the bearer token and answers with a 401. Give it requiredScopes: ["ap:read"], which is why the 401 names ap:read. Its own descriptions are Missing Authorization header, Token has expired and so on, so the build rebuilds every 401 with bearerAuthChallengeResponse to get docs/auth-spec.md's wording.
  • requireScopes builds a per-tool scope check, attached with registerTool's scopeChallenge option: requireScopes("ap:read", "ap:vendors.read") for find_vendor. Its 403 says only Insufficient scope, so a small wrapper adds the auth-spec wording.

The token check itself is a small function you supply, the OAuthTokenVerifier, which the lab writes with the jose library, version 6.2.15. It fetches the public keys, checks signature, issuer, audience and expiry, and returns the user, scopes and tenant. The SDK refuses a token whose result has no expiresAt.

The Python SDK, mcp 2.3.0, is different in three ways:

  • No per-tool scope check. Its AuthSettings.required_scopes applies to every request. Setting both scopes there would lock auditors out. So set required_scopes=["ap:read"], and write a small gate after the SDK's bearer check that answers 403 insufficient_scope exactly as the TypeScript one does. The SDK's document lists required_scopes as scopes_supported, so replace its route with create_protected_resource_routes(..., scopes_supported=["ap:read", "ap:vendors.read"]).
  • The audience. Set validate_token_resource=True and fill AccessToken.resource from the token's aud: the SDK then refuses a token made for another connector even if your verifier forgets. Its default, None, gives a warning and behaves as off (the SDK says 3.0 will turn it on). It compares loosely and ignores a trailing slash, so your verifier must still check the audience exactly, with PyJWT's audience= and strict_aud.
  • Expiry. The SDK accepts a result with no expires_at, so the verifier must require exp itself: options={"require": ["exp", "iss", "aud", "sub"]}.

The verifier is a TokenVerifier written with PyJWT 2.15.1 and cryptography. PyJWKClient fetches the public keys with blocking calls, so in the async server run it with asyncio.to_thread. A tool reads the checked token from ctx.request_context.request.scope["user"].access_token. In a Python Worker online, the key fetch must go through the AUTH binding instead, which the authors' build has not written or run.

Both libraries fetch the keys again for an unknown kid at most once per 30 seconds, and anyway on a timer: jose every 10 minutes, PyJWT every 5.

F5. Claude Code

What has run in a real browser. On November 2, 2026, two sign-ins ran in a real browser on one computer. One was Task 1, through the MCP Inspector 2.13.0. The other was Task 13's first sign-in, from Claude Code 2.1.316, with A1, A5 and A6 answered through it. That run found the form-action problem in F3.

Not yet run by a person in a browser: the rest of F5 to F7, which is Priya's two sign-ins and the step-up in Claude Code, and all of Codex and OpenCode. The authors ran the same sign-ins with a script, with each app's client ID and return address.

Sign in. Add the connector by its address, with nothing else:

claude mcp add --transport http apts http://127.0.0.1:8717/mcp

Then, in Claude Code, type /mcp, choose apts, and choose to authenticate. Your browser opens at the sign-in server. Sign in as the test user (F0), pick the company, approve. Or run claude mcp login apts in a terminal (--no-browser prints the address instead of opening it). A server from .mcp.json must be approved first, so start claude in the folder once and approve it.

Sign out. claude mcp logout apts.

Scopes. It asks for the scope in the 401 first. To ask for more up front, set oauth.scopes in the server's entry, as one space-separated string. It adds offline_access when the sign-in server lists it, to get a refresh token.

On a 401, it refreshes the token, reconnects and retries once. If the refresh is refused, it tells you to sign in again from /mcp. On a 403 insufficient_scope, it shows that the server needs more permissions.

Note on localhost. Claude Code's pre-registered return address uses localhost. It means the same computer as 127.0.0.1, but the sign-in server compares the two as different addresses, and only the port may vary. So the client in the sign-in server must list exactly the address Claude Code sends, localhost included, which npm run client:add does.

Pre-registration, the lab's default for Claude Code: claude mcp add --transport http apts http://127.0.0.1:8717/mcp --client-id <id> --callback-port 8790. In .mcp.json the same entry reads "apts": { "type": "http", "url": "http://127.0.0.1:8717/mcp", "oauth": { "clientId": "<id>", "callbackPort": 8790, "scopes": "ap:read ap:vendors.read" } }. The sign-in server's client then lists http://localhost:8790/callback as its redirect. Make the client with the lab's npm run client:add script. Never use --client-secret for a lab client.

From your own program. The Messages API's MCP connector takes a token you already have, as authorization_token, with the beta header mcp-client-2025-11-20. A newer header, mcp-client-2026-09-15, adds pinning the tool list and works only on Anthropic's own platform, not through cloud providers that resell Claude.

Two notes. claude -p and the Agent SDK cannot complete a browser sign-in, so sign in interactively first. A headersHelper that sets Authorization wins over sign-in. Remove any left from Chapter 17.

F6. Codex

Sign in. In .codex/config.toml:

[mcp_servers.apts]
url = "http://127.0.0.1:8717/mcp"

No bearer_token_env_var: that was the shared key. Then run codex mcp login apts. Codex uses a Client ID Metadata Document when the sign-in server advertises one, lists none for token authentication, and accepts its loopback callback on any port. All three are true of the lab's sign-in server (F4 step 3). --oauth-client-registration cimd forces that route. --scopes asks for scopes for this sign-in.

Each version of the connector has its own address in the sign-in server, the TypeScript one 8717 and the Python one 8718, so an AI app can sign in to either. A token names the one it was made for.

Issuer. Codex checks iss before exchanging the code, and rejects a missing iss when the server advertises support.

Pre-registration, if needed: codex mcp add apts --url http://127.0.0.1:8717/mcp --oauth-client-id <id>. Do not use --oauth-client-secret: Codex saves it in config.toml.

F7. OpenCode version 2

Sign in. In opencode.jsonc, under mcp.servers, remove "oauth": false and the headers from Chapter 17:

"apts": { "type": "remote", "url": "http://127.0.0.1:8717/mcp", "protocol": "2026-07-28" }

Sign-in is on by default for remote servers. Run opencode mcp auth apts, or type /mcps in OpenCode and authorize. opencode mcp logout apts signs out.

Registration. OpenCode uses a configured client_id first, then a Client ID Metadata Document if the server supports one and you have not set redirect_uri. Otherwise it falls back to the older route, which Brightline's sign-in server does not offer. The lab pre-registers OpenCode (Task 15): make a client with npm run client:add -- --client-id brightline-opencode --name "OpenCode (lab)" --redirect http://127.0.0.1/callback, then the entry reads "apts": { "type": "remote", "url": "http://127.0.0.1:8717/mcp", "protocol": "2026-07-28", "oauth": { "client_id": "brightline-opencode", "scope": "ap:read ap:vendors.read", "redirect_uri": "http://127.0.0.1:8792/callback" } }. OpenCode's page names loopback return addresses with the path /callback. Setting redirect_uri fixes the port OpenCode listens on, 8792, and turns the document route off. The client's registered address, http://127.0.0.1/callback, names no port, and a loopback return address may use any port (F4).

Scopes. "oauth": { "scope": "ap:read ap:vendors.read" } to ask for more up front.

F8. Deploying the sign-in server

Not yet run online: the push to GitHub, and the deploys to Cloudflare staging and production. The authors rehearsed staging on their own computer, with both Workers in Cloudflare's local runtime, and the suite ended 27 checks passed, 0 failed.

Order. The sign-in server goes online before the connector, in each environment, because the connector's settings name it.

Your first deploy, in order.

  1. By you, if you have none: a Cloudflare account on the free plan, and a workers.dev subdomain. That is the free web address your Workers live under, which you choose in the Cloudflare dashboard under Workers & Pages.
  2. Wrangler signs in. An agent runs npx wrangler login, and you approve it in the browser.
  3. The databases. An agent runs npx wrangler d1 create brightline-auth-staging and npx wrangler d1 create brightline-auth, and writes each database_id into servers/auth/wrangler.jsonc, staging under env.staging and production under env.production (whose Worker is named brightline-auth). A database_id is not secret. The file's top level stays the sign-in server on your computer, which wrangler dev reads, so every deploy, migration and secret names its environment: deploy:auth runs wrangler deploy --env production. The connector keeps Chapter 17's shape, with production at the top level.
  4. Migrations, per environment: npm run migrate:auth:staging (it runs npx wrangler d1 migrations apply brightline-auth-staging --remote --env staging). Production's is npm run migrate:auth:production (--env production), and it runs only through the workflow. The ask rule shows you each one first.
  5. Secrets, per environment: npm run secrets:auth:staging sends BETTER_AUTH_SECRET and AUTH_ADMIN_KEY from .secrets/auth.staging.env with wrangler secret bulk, printing none. Never send .secrets/staging.env to the sign-in server: wrangler secret bulk sends every line of a file, and that one holds the connector's keys.
  6. Deploy the sign-in server: npm run deploy:auth:staging. Its settings in wrangler.jsonc include MCP_RESOURCE and MCP_RESOURCE_PY, each connector's staging address ending in /mcp. An app's token names, as its audience, the one it asked for. Each environment sets its own. Its address is https://brightline-auth-staging.<your-subdomain>.workers.dev.
  7. Point the connector at it. In servers/ap-ts/wrangler.jsonc, under env.staging, set AP_ISSUER (the sign-in server's issuer) and AP_RESOURCE (the connector's own staging address ending in /mcp). Add a service binding, AUTH, to brightline-auth-staging, through which the connector fetches the public keys. A service binding is a direct link between two Workers in one account, so the connector never fetches the sign-in server through its public address. On your computer wrangler dev creates the binding too, with no sign-in Worker behind it, so the connector uses the binding only when AP_ISSUER is not a loopback address. These are settings, not secrets.
  8. Send the connector's handle key, then deploy it: npm run secrets:staging, then npm run deploy:staging, in servers/ap-ts. (Secrets first, as for the sign-in server, so the connector never runs without them.)
  9. Onboard the tenants in staging with the same script as on your computer: npm run tenant:add -- --env staging .... Staging test users' passwords go into .secrets/test-users.staging.env.
  10. Check it: npm run client:ci -- --env staging once, then npm run conformance:staging. Done only at 0 failed. It reads the staging addresses from .secrets/staging.urls.env (CONFORMANCE_URLS=ts=<the connector's address>, AP_ISSUER and AUTH_URL, which are not secrets) and the CI client from .secrets/ci.staging.env.

Production repeats steps 4 to 10 with production's names (step 3 already made both databases). Its steps fall into two groups. By hand, from your computer, by the agent with your approval: step 5 (the sign-in server's secrets) and step 7 (the connector's settings). Also by hand is the first half of step 8, sending the handle key with npm run secrets:production. Through the workflow only, started by you: step 4 (the migrations), step 6 (the sign-in server's deploy) and the second half of step 8 (the connector's deploy).

The workflow's own secrets. The workflow deploys with two Cloudflare secrets in GitHub. CLOUDFLARE_API_TOKEN, the deploy token: an agent sends it with gh secret set CLOUDFLARE_API_TOKEN < .secrets/cloudflare-token. CLOUDFLARE_ACCOUNT_ID: you find it in the Cloudflare dashboard, under Workers & Pages, Account Details, Account ID. Then set it in your own terminal with gh secret set CLOUDFLARE_ACCOUNT_ID, pasting it when asked, never into the chat.

The test-only signing key is never deployed. Locally, tests sign tokens with AP_TEST_SIGNING_KEY, and the connector trusts it only when AP_TRUST_TEST_ISSUER=1. npm run check:deploy, at the repository's root, fails if that setting, or that key, appears in any environment in either wrangler.jsonc, or if an environment's sign-in server and connector do not name each other. Then it dry-runs both Workers in both environments. It lists every placeholder still waiting for a real value, such as your-subdomain, and each deploy script refuses to run while one is left. CI runs check:deploy on every pull request.

Tokens for CI. A browser sign-in cannot run in CI. So a pre-registered CI client gets tokens with no person, through OAuth's client credentials grant, which Better Auth allows only with scopes an admin assigned (ap:read and ap:vendors.read). The client belongs to a fourth, invented company, Conformance Test Co., so its tokens carry that tenant. The connector serves it only a copy of the sample rows, marked as test data. The deployed suite never reads a real company's data. Four of its checks prove the CI client cannot get a Brightline or Cedar Ridge token. Each token says org_conformance, even when the request names another company, and the client cannot start a browser sign-in. npm run client:ci -- --env <environment> makes the client's ID and secret into .secrets/github.env, for GitHub, and .secrets/ci.<environment>.env, for npm run conformance:<environment>. An agent sends the first with gh secret set -f .secrets/github.env. The authors' build confirmed that such a token can carry tenant. For a token with no person, customAccessTokenClaims receives no user and no company, only the client's own metadata. So the admin route that creates the CI client writes {"tenant": "org_conformance"} into its metadata, and the claims function reads tenant from there. The client also needs its client-credentials scopes assigned by the admin route, or Better Auth refuses it. The CI client must always send resource: without it, the reply is a plain random token that is not a JWT, and the connector refuses it.

Replacing the handle key. The connector's handle key, which seals page handles (17.4), is replaced the same way as a signing key: for a short time the connector holds two. It seals new handles with the new key, and still opens handles sealed with the old one (AP_HANDLE_KEY_PREVIOUS). After 15 minutes, how long a handle lasts, the old one is removed. npm run keys -- --rotate-handle <environment> makes the new key and keeps the old one as AP_HANDLE_KEY_PREVIOUS, in that environment's file, printing neither. Send the file with npm run secrets:<environment>. After 15 minutes, npm run keys -- --drop-previous <environment> removes it from the file, and an agent runs npx wrangler secret delete AP_HANDLE_KEY_PREVIOUS, because sending a file never deletes a secret.

Replacing the signing key. Better Auth 1.7.15 has no command for it: it replaces an expired key only at the next token. So the lab adds an admin route, called by npm run signing-key:rotate, that makes the new key at once. scheduled: the old key stops sealing now and stays published for one more day. leaked: the old keys are deleted. The new key is published from the moment it first seals. So a connector that fetched the public keys in the last 30 seconds refuses the first new tokens for up to 30 seconds. After a leak, the connectors still hold the old key for up to 10 minutes, so redeploy them to drop it at once. If BETTER_AUTH_SECRET may have leaked too, replace it and rotate again with leaked, because it encrypts the private keys.

Rollback with a database. Rolling a Worker back to an earlier version does not roll its database back. So a migration must work with both the old and the new code: add columns and tables first, deploy, and remove old ones only in a later migration. If a migration itself is wrong, Wrangler's backup from step 4 is the way back, with a person deciding.

What the free plan gives, and what Brightline uses. Workers: 100,000 requests a day and 10 ms of processor time per request, with flexibility for a Worker that "infrequently runs over". D1: F2. Sign-in hashes a password, which is the heaviest step. Wrangler on your computer shows only the total time of a request (POST /sign-in 302 Found (172ms)), not processor time. The authors measured the hash itself in Cloudflare's local runtime: about 80 ms of processor time per sign-in, roughly 8 times the free plan's 10 ms. Not yet run online: whether Cloudflare's flexibility absorbs that. It needs a person to deploy the sign-in server and sign in through it. If the deployed sign-in server reports Error 1102, "Worker exceeded resource limits", during sign-in, note it in ports/ch18.md. Whether sign-in fits the free plan is a known open question for this lab. The conformance suite signs its test people in with passwords too, so its sign-in rows will fail as well. Record the failing rows by name and treat them as blocked by the free plan. If sign-in fails every time online, record it, mark Task 18's online sign-in as blocked by the free plan, and carry on: it counts as done for this lab. The Rate Limiting binding goes in servers/ap-ts/wrangler.jsonc as "ratelimits": [{ "name": "RATE_COMPANY", "namespace_id": "1801", "simple": { "limit": 600, "period": 60 } }, …], one entry per limit, repeated under env.staging (period may be only 10 or 60). Its own call, limit({ key }), answers { success }, which is exactly the lab's RateLimiter shape, so the binding is the limiter. wrangler deploy --dry-run accepts it. It is not listed in the free plan's features. Not yet run online: whether a free-plan deploy accepts it. It needs a person to deploy. If the deploy refuses it, follow Task 9b's fallback.

F9. The multi-tenant sign-in checklist

Work through this for every change to the connector or the sign-in server, as a reviewer. Reviewers' words: tenant means company, user means person.

The tenant's source.

  • The tenant is read in one place only: the token check.
  • No header, tool input, address or model text is read as a tenant anywhere. Search for tenant, company, org and X- in the change.
  • A tool input naming a company is refused or ignored, never used. TypeScript's strict input schemas refuse it, and the Python SDK silently ignores it. Either passes.
  • A token with no tenant claim gets 401.

The request context.

  • The token check makes one locked order slip (request context), and every tool takes it.
  • No module-level variable or cache holds a tenant, a user or anything stored under them. The public keys, and the rate limiter's counters (numbers kept per company and person, inside the limiter or Cloudflare's binding, never data), are the only shared state.

What carries the tenant.

  • Page handles hold the tenant and user, and are refused on any difference.
  • Rate limits are counted per company, with a per-person limit inside.
  • Every tool call writes one log line, with tenant, user, app, tool, outcome and duration, and no token, input or result. A refusal before the token is checked has no tenant or user to name.
  • A tenant with no data connected gets the exact "No AP data is connected" error from every tool, never another company's data.

The token.

  • Signature, iss, aud (this connector exactly) and exp are checked on every request, in both versions.
  • No token is passed on to any other service.
  • Each tool's scope is checked, and a 403 names every scope the call needs.

The sign-in server.

  • The company picker lists only the person's memberships, and the token is refused for a non-member even when the request is crafted.
  • Membership, the role's scopes and whether the company still takes tokens are checked again on refresh, by the sign-in server's own claims function (customAccessTokenClaims). Better Auth does not do this by itself. A refused refresh leaves the refresh token in place, so removing a person must also delete their refresh tokens. After a role change, a refresh that asks only for the scopes the new role allows succeeds.
  • Refresh tokens are replaced on every use, with no grace period.
  • The test-only signing key cannot be on in any deployed environment.

The tests.

  • The sign-in matrix (18.13) passes against both versions.
  • Each planted bug fails at least one check.

What Chapter 19's checklist adds: the database refuses another tenant's rows on its own, errors never reveal another tenant, and a tenant's data can be exported and deleted.

F10. How today's tools realize it, in detail

The product-by-product detail behind 18.17, checked on each maker's own pages on November 2, 2026, against MCP revision 2026-07-28.

What kind of evidence this is. Each statement below is the product's own documented behavior, read on its first-party page on November 2, 2026, and numbered to its source. "Not documented" means the authors read the page and did not find it, not that the product lacks it. Your Lab 18 port notes, in ports/ch18.md, are the test of each.

What the specification asksClaude Code1Codex2OpenCode version 23
Reads protected resource metadata, then the sign-in server's metadataYesUses advertised scopes. Discovery order not documentedYes, uses the scope the 401 names
Registration route it tries firstA Client ID Metadata Document when the server supports one, found automatically. Pre-registered with --client-idA Client ID Metadata Document when the sign-in server advertises one, lists none for token authentication, and accepts its loopback addressA configured client_id first, then a Client ID Metadata Document, then the older route this book does not teach
Checks the issuer before swapping the codeFails a sign-in "whose authorization response names an unexpected issuer"Yes, before exchanging the code, and rejects a missing issuer when the server advertises itNot documented
Handles a 403 insufficient_scopeShows that the server needs more permissionsNot documentedNot documented
RefreshesOn a 401, refreshes, reconnects and retries once. A rejected refresh asks you to sign in again in /mcpNot documentedRefreshes tokens
Where tokens are kept"Stored securely". A client secret goes in the keychain on macOS or a credentials fileNot documented. A client secret, if you set one, is saved in config.toml"Outside project configuration"
Signs in without a browser, for CINo. Sign in interactively first. claude -p and the Agent SDK cannot complete the browser sign-inNot documentedNot documented

How each one differs from the specification

Anthropic.

  • Claude Code. Signs in from /mcp or claude mcp login <name>, after a 401 or 403 marks a server as needing it. Follows protected resource metadata, then the sign-in server's metadata. Supports Client ID Metadata Documents and finds them by itself, and pre-registered clients with --client-id, --client-secret and --callback-port, which sets a fixed http://localhost:PORT/callback. Asks for a refresh token when the sign-in server offers one. Adds an automatic refresh and one retry on a 401.1
  • Relaxes nothing we found, but an older setting that adds a fixed header to every request (headersHelper) wins over sign-in when it sets Authorization. A leftover one from Chapter 17's shared key would silently replace your token. Remove it.
  • The Messages API's MCP connector takes an authorization_token per server. It does not run a sign-in. Your program gets the token, refreshes it, and passes it in. It is in beta on every model (field guide F5 has the exact setting), and Anthropic's option to keep no copy of your data does not cover it. Chapter 20 keeps such tokens per user and company.4
  • Status and price. Claude Code needs a Claude plan or API billing. The connector is billed like any Messages request.

OpenAI.

  • Codex. codex mcp login <name>, with --scopes for one sign-in. Uses a Client ID Metadata Document when the sign-in server's document says three things. It supports these documents, it accepts apps that have no secret of their own, and it accepts Codex's return address on any port. Its document lives at https://chatgpt.com/oauth/codex/<callback_id>/client.json. --oauth-client-registration cimd forces that route for one sign-in. Checks the issuer before exchanging the code. A pre-registered client uses --oauth-client-id, and a client secret given with --oauth-client-secret is saved in config.toml, so keep pre-registered Codex clients public, with no secret.2
  • The Responses API's MCP tool takes the token in an authorization field. OpenAI does not store it, so your program sends it with every request. Like Anthropic's, it does not run a sign-in.5
  • Status and price. Which ChatGPT plans include Codex was not rechecked on November 2.

Open source.

  • OpenCode version 2. Sign-in is on by default for remote servers. opencode mcp auth <name>, or /mcps, signs in through the browser with PKCE, and opencode mcp logout <name> signs out. Uses a configured client_id first, then a Client ID Metadata Document when the server supports it and no redirect_uri is set. Setting redirect_uri turns Client ID Metadata Documents off.3
  • Falls back to the older registration route this book does not teach. Brightline's sign-in server does not offer it, so if OpenCode's metadata document is not accepted, pre-register a client for it (field guide F7).
  • Security. The issuer check is not documented. Record what your run shows in ports/ch18.md.
  • Status and price. Free and open source. You pay for the model.

Where the sign-in runs

Better Auth, on a Cloudflare Worker, with D1. Better Auth is an open-source sign-in library for TypeScript: code you run inside your own program, not a service you sign up for. You add features as plugins, which here means Better Auth's own add-ons, not Chapter 16's plugins. Brightline's sign-in server is a Cloudflare Worker (17.11) running Better Auth 1.7.15, the newest stable release on November 2, 2026. Version 1.8 is in beta and not used.6

  • Four plugins. mcp() makes it an OAuth authorization server for an MCP connector, and its own page says not to add the general OAuth provider plugin beside it.7 jwt() seals tokens and publishes the public keys, with the private halves encrypted.8 organization() adds companies, memberships and roles.9 cimd() accepts Client ID Metadata Documents, with the MCP 2026-07-28 profile.10
  • Implements, per the specification. Protected resource metadata and authorization server metadata, PKCE, the resource parameter with the token's audience set to it, scopes, short-lived tokens and refresh tokens replaced on every use, issued only when the app asks for offline_access.11 The older registration route is off unless you turn it on, and the lab never does.
  • What it leaves to you on Workers. Its secure fetcher for Client ID Metadata Documents is for Node only. Other runtimes, Workers included, "must provide the equivalent secure transport", which a Worker cannot fully do because it cannot choose the exact network address it connects to. The lab therefore accepts documents only from a short list of known hosts (18.5, field guide F3).
  • What it cannot do with signed tokens. Its own page says signed access tokens "cannot be revoked server-side", which is why 18.6 keeps them to 10 minutes.
  • D1 is Cloudflare's database service. Each D1 database is a SQLite database, a small kind of relational database (18.8) that many phones and apps use, run by Cloudflare for you. A Worker reaches it through a binding, Chapter 17's word for a named link from a Worker to a platform resource. Better Auth uses that binding directly.12 Free plan: 10 databases, 500 MB each, 5 GB in all, 5 million rows read and 100,000 rows written a day, and 50 queries per Worker request. Brightline's sign-in server uses a tiny fraction.13
  • Free plan limits that matter here. Workers give 10 ms of processor time per request on the free plan, with some flexibility for a Worker that "infrequently runs over".14 Password hashing at sign-in is the heaviest step. The authors measured about 80 ms of processor time per sign-in in Cloudflare's local runtime, roughly 8 times the 10 ms. Not yet run online: whether a deployed sign-in still succeeds, or fails with Error 1102. It needs a person to deploy and sign in. The connector fetches the sign-in server's public keys through a service binding, a direct link between two Workers in one account that does not go through a public address.15 Cloudflare's own Rate Limiting binding, for rule 5 in 18.12, is not listed in the free plan's features. Lab 18 tries it, and if it is refused, uses an in-memory limiter online and records that the limit is then counted per copy, not across all copies.16
  • Alternatives. Cloudflare's own Workers OAuth Provider library runs an authorization server inside a Worker. It supports Client ID Metadata Documents (with a setting that limits its fetches to public addresses), and replaces refresh tokens on every use. A hosted identity service runs the sign-in server for you, for a monthly price. All three serve the same connector unchanged, because the connector checks standard tokens against standard public keys.17
  • Status and price. Better Auth is free and open source. Workers and D1 have the free plans above.

The tenancy models, product by product

All three tenancy models from 18.9 work with every AI app above, because each model is only a question of what address the connector has and what audience its tokens carry. The difference is in the sign-in server: Better Auth's organization() plugin, with consent stored per company, supports the first model directly. The second model needs one connector address per company in the sign-in server's settings, so check that the pinned version accepts several before choosing it. The third model is one more deployment of everything.

Watch this: DPoP

DPoP (Demonstrating Proof of Possession, RFC 9449) binds a token to a key held by the AI app, so a stolen token is useless without that key. The app signs a small proof for each request. The MCP specification does not require it in revision 2026-07-28. Better Auth's connector helpers enforce it when a token is bound this way. Watch for AI apps adopting it, and for a later MCP revision asking for it. Brightline does not use it yet.18

Check yourself

Question 1 / 5

0 answered

According to its documentation, what does Codex do before it exchanges a sign-in code for tokens?

  1. Connect Claude Code to tools via MCP, Anthropic.↑↑2
  2. Codex: MCP, OpenAI.↑↑2
  3. MCP servers, OpenCode.↑↑2
  1. MCP connector, Anthropic.↑
  2. Connectors and MCP servers, OpenAI.↑
  3. Changelog, Better Auth.↑
  4. MCP plugin, Better Auth.↑
  5. JWT plugin, Better Auth.↑
  6. Organization plugin, Better Auth.↑
  7. CIMD plugin, Better Auth.↑
  8. OAuth provider, Better Auth.↑
  9. Better Auth 1.5, Better Auth.↑
  10. D1 limits, Cloudflare.↑
  11. Workers limits, Cloudflare.↑
  12. Service bindings, Cloudflare.↑
  13. Rate Limiting, Cloudflare.↑
  14. Workers OAuth Provider, Cloudflare.↑
  15. RFC 9449, OAuth 2.0 Demonstrating Proof of Possession, IETF.↑
Owner
Panaversity