Build Flows

Custom applications · October 9, 2026 · 10 min read

Headless Auth: Personal Access Tokens, Embed Tokens and One Door per Credential

How Connect handles browser sessions, CLI personal access tokens, cross-site embed tokens and MCP OAuth, with each credential confined to one route scope and resolved to the same authorization rules.

By Charley Forey, founder of Build Flows

A web app with one login screen has one authentication problem. An AI platform has several. People sign in through a browser. A command-line tool needs to push dashboards without a browser. A client portal wants to embed a chat widget in a page on someone else's domain. Claude and other MCP clients want to call tools on a user's behalf. Each of those is a different kind of caller, and each wants a credential that fits.

The easy mistake is to grow one credential that does everything: an API key that works on every endpoint, or a session cookie that a script copies out of a browser. When we built Connect, the AI platform on top of Syncify, we went the other way. Every credential type gets exactly one door into the application, and every door leads to the same authorization rules. This article covers how that works for sessions, personal access tokens (PATs) and embed tokens, and where MCP fits.

The principle: one credential, one door

Connect's API is a Fastify app, and its routes are organized into scopes: nested plugins, each with its own authentication hooks at the top. Fastify's plugin encapsulation guarantees that a hook added inside a plugin applies to every route in that plugin and to nothing outside it. That property is what makes "one door" enforceable rather than aspirational.

CredentialWho uses itAccepted onCan it reach other scopes?
Session cookiePeople in the browserEvery authenticated scopeYes, it is the primary credential
Personal access tokenScripts, the local app-agent CLIThe apps scope onlyNo
Embed tokenA chat widget on a third-party pageThe embed-chat scope onlyNo
MCP access tokenClaude and other MCP clientsThe MCP endpoint onlyNo

The routes file holds a manifest of every route with its auth level, and a test asserts that the registered routes match it exactly. A new route therefore cannot pick up a credential type by accident. It is accepted by whatever its scope accepts, and the scope is visible in review. (The manifest and the RBAC chokepoint are covered in depth in multi-tenant RBAC with a single authorization chokepoint.)

Door one: Syncify SSO and Connect-owned sessions

Connect has no password table. Users sign in with their Syncify account through an OAuth-style authorization code flow with PKCE:

  1. /auth/login generates a random state and a random PKCE verifier, and derives the S256 challenge from the verifier.
  2. The state, verifier, return path and an expiry (ten minutes) are signed into a short-lived transient cookie. Nothing is written to the database yet.
  3. The browser is redirected to Syncify's authorize endpoint with the challenge.
  4. Syncify redirects back to /auth/callback with a code and the state.
  5. Connect verifies the transient cookie's signature and expiry, compares the state in constant time, validates the shape of the code, and exchanges the code plus the verifier for Syncify tokens.
  6. Connect upserts the user, stores Syncify's tokens encrypted (a versioned key, a nonce and an auth tag per row), and then mints its own session.

That last step is the important one. The session the browser holds is not a Syncify token. It is an opaque 32-byte random value that Connect generated, and Postgres stores only its SHA-256 hash. The sessions table has no surrogate ID at all: the hash is the primary key, because a second thing that identifies a session is a second thing to get wrong.

A few details that came out of real decisions:

  • No HMAC on the session token. Signing a random token authenticates nothing the database lookup does not already establish, and it adds a second secret to rotate.
  • Session policy: idle expiry of 7 days, absolute expiry of 30 days, and a fresh session on every login.
  • Return paths are normalized. Only same-site relative paths survive, and anything else becomes /. That closes the classic open-redirect hole in login flows.
  • The cookie is SameSite=Lax, not Strict. Connector OAuth flows (for Procore, for example) return through a cross-site top-level GET, and Strict would drop the cookie needed to bind that callback to its user. Lax is not enough on its own, so every cookie-authenticated mutation also passes a same-origin check.
  • Refresh tokens are never retried. When Connect refreshes a user's upstream Syncify token and the call fails, it deletes the stored session rather than retrying, because a rotating refresh token may already have been consumed.

The same PKCE round trip has a second purpose. A workspace member can ask Syncify for a durable API key for a data connection, and the signed transient cookie then carries the workspace and user it was requested for. If a different Syncify account comes back through the callback, the key is rejected.

Door two: personal access tokens for headless work

Connect lets users build small dashboards ("apps") that run in a sandboxed iframe. Developers wanted to build those locally and push them from a terminal with a small CLI. That needs a credential that works without a browser, which means a bearer token.

PATs are defined in their own migration and minted through a small set of routes. Their properties, roughly in order of importance:

They never carry more authority than their user. A valid PAT resolves to the user who issued it and populates the request context exactly as a cookie session would. Every downstream hook (workspace membership, feature entitlement, role level, project scope) then runs unchanged on every request. Revoke the user's membership or their role, and their tokens lose that access on the next call. The token has no permissions of its own to fall out of sync.

They are pinned to one workspace. A PAT records the workspace it was minted for. After membership is verified for the workspace in the path, a separate hook checks the two match. A user who belongs to five workspaces can mint a token that only works in one of them. A mismatch returns the same 403 a non-member would get, so a token cannot be used to probe for other workspaces.

They only work on the apps scope. This is the "one door" rule in action. There is a single hook, sessionOrPat, that accepts either a bearer PAT or the session cookie. It is registered on the apps scope and nowhere else. Every other authenticated scope uses the cookie-only session hook. Because of plugin encapsulation, there is no code path by which a PAT reaches projects, files, schedules, users or admin routes, no matter what endpoint it is sent to.

You cannot mint a token with a token. The routes that create, list and revoke PATs live in the cookie-only member scope. If a leaked PAT could create new PATs, revoking it would not end the incident.

They are prefixed, hashed and shown once. Every token starts with a fixed, recognizable prefix, the same idea as the prefixes on GitHub tokens. A leaked token is easy to grep for in logs, a paste into the wrong field is obvious, and the server can reject garbage before touching the database. The database stores a SHA-256 hash and a short non-secret hint (the prefix plus a few characters) so users can tell their tokens apart in a list. The plaintext is returned exactly once, in the creation response.

They expire by default. The default lifetime is 90 days and the maximum is 365. A user can explicitly choose "never expires", but has to ask for it. Revocation stamps a revoked_at column rather than deleting the row, and lookups touch last_used_at, so the management screen can show stale tokens.

They skip CSRF, for a reason. The same-origin check exists to stop a malicious page from riding a browser's cookie. A PAT request carries no cookie, and an attacker who cannot read the token cannot forge it, so the check is skipped for PAT requests only.

In request terms, the apps scope looks like this (illustrative, not repo code):

apps scope:
  onRequest:  sessionOrPat      -> user from bearer PAT, else from cookie, else 401
  onRequest:  sameOrigin        -> skipped when the request used a PAT
  preHandler: requireMembership -> path workspace must be one of the user's
  preHandler: requirePatWorkspace -> PAT's pinned workspace must equal the path's
  preHandler: requirePermission("workspace.read")
  preHandler: requireProjectAccess
  routes:     apps:0 group, apps:1 group

There is also a small "who am I" endpoint on the same scope. The CLI's login command calls it so that a bad token fails immediately, with a clear message, instead of at the first push.

One boundary is worth calling out. App publishing works over a PAT, but the interactive App Builder agent does not. A workspace can hold Apps without holding the agent that writes them, and a PAT is for the apps HTTP surface, not for driving an AI session.

Door three: embed tokens for a cross-site chat widget

Connect's assistant can be embedded in another site, such as a client portal, as a chat widget. That page loads Connect in a cross-site iframe, where the SameSite session cookie is not sent, so the widget needs its own credential.

Embed tokens follow the same rules as the other doors. A workspace admin mints them from a cookie-only scope. They are short-lived (24 hours at most), stored only as a hash, and shown once. They are accepted in a scope that holds the embed-chat routes and nothing else. The workspace, the project scope and the acting principal all come from the validated token, never from the request, and each embedded conversation is bound to the token that created it. We cover the widget, the token's limits and a rollout checklist in an embeddable AI chat widget for the enterprise.

Where MCP fits

MCP clients such as Claude get a fourth door. The endpoint follows the MCP authorization spec: protected-resource metadata, dynamic client registration, an authorization code flow with S256 PKCE required, and bearer access tokens. The user approves access by signing in through the normal SSO session, and the issued token is signed but also registered in the same session store the web app uses, so ending the session revokes it. It resolves to the same user, and checked through the same membership and feature gates. A missing feature answers "workspace not found", exactly like non-membership.

We cover that surface separately in building an enterprise MCP server with OAuth. The point here is that it is not a special case. It is one more credential with one more door.

What every door has in common

Looking across all four credentials, the same few rules repeat:

  • Store hashes, never secrets. Sessions, PATs and embed tokens are opaque bearer values, and their SHA-256 hash is the only form Postgres ever sees. A database leak does not hand anyone a working credential. MCP tokens are signed instead, and still checked against a session row so they can be revoked.
  • Show plaintext once. Every token is displayed at creation and never again.
  • Resolve to a user, then reuse the normal checks. No credential carries its own permission list. Each one answers "who is this?" and the shared hooks answer "what can they do?"
  • Confine by scope, not by convention. The guarantee that a PAT cannot reach the files API comes from where the hook is registered, not from a check someone has to remember to add.
  • Deny uniformly. 401 for a bad credential, 403 for anything out of reach, with the same body whatever the reason.

Why this matters if you're building something similar

If your product is growing integrations, a CLI or an embeddable widget, you will be asked for "an API key." Before you build one, it is worth asking these questions:

  1. What exactly does this caller need to reach? Give it a scope that holds those routes and nothing else. If your framework does not offer encapsulated middleware, build the equivalent with an explicit allowlist and a test.
  2. Whose authority does it carry? Tying a token to a user and re-checking that user's live access on every request means revocation works and privilege cannot drift. Tokens with their own permission sets need their own admin screens, audit trail and offboarding process.
  3. Where can it be used? Pin tokens to a tenant. Multi-tenant users are common in construction, where consultants and owners' reps work across many firms.
  4. How long should it live? Default to expiry. Make "never" an explicit choice.
  5. Can it create more of itself? It should not.
  6. What does a leak look like? A recognizable prefix, hashed storage, last-used timestamps and soft revocation make an incident a ten-minute job instead of a forensic project.

The cost of this approach is a little more wiring: four credential hooks instead of one, and a few more tests. The benefit is that every new integration request becomes a short conversation about which door it gets, rather than a debate about how much of the system a key should unlock.

Where to go next

Need headless access or an embedded assistant designed into your platform? Talk to us about an engagement.

Frequently asked questions

What is the difference between a personal access token and an API key?

In Connect a personal access token has no permissions of its own. It resolves to the user who created it, and every request re-checks that user's live membership, roles and project scope. A classic API key usually carries its own permission set.

Why can't a personal access token create another token?

If a leaked token could mint new tokens, revoking the leaked one would not end the incident. Token management routes only accept the browser session cookie.

Do bearer tokens need CSRF protection?

No. CSRF abuses a cookie the browser sends automatically. A bearer token must be attached explicitly and an attacker cannot read it, so Connect skips the same-origin check for token requests and keeps it for every cookie-authenticated mutation.

How long should an embed token live?

Connect clamps embed tokens to between one minute and 24 hours, because the token sits in a host page. Personal access tokens default to 90 days with a 365-day maximum.

How does MCP authentication fit in?

MCP clients use OAuth with dynamic client registration and S256 PKCE. The issued token is stored hashed, resolves to the signed-in user, and is checked through the same membership and feature gates as every other credential.

Next step

Need something built around how your team works?

Describe the users, the workflow, and the systems it touches. We'll tell you whether a custom application makes sense and how we'd build it.

Prefer email? charley@buildflows.ai

Get the next guide in your inbox

Field Notes: practical guides and new walkthroughs, about once a month.

Field Notes

Practical guides and new walkthroughs on construction data and automation, roughly monthly.

Keep learning