API keys, access tokens, Email & OAuth
Personal access keys, access / refresh token pair, per-workspace email transports, and OAuth provider wiring.
This page covers the bearer-token + outbound-mail surfaces that ship with Backlex’s auth plane (Phase 5): personal access keys (PAKs), the access / refresh token pair issued to workspace end-users, email transport selection (env-level + per-workspace overrides), and OAuth social providers.
Access / refresh tokens (workspace end-users)
Section titled “Access / refresh tokens (workspace end-users)”Every app-plane sign-in issues a token pair:
- The
app_sessionsrow is the long-lived, revocable refresh token (its opaqueapp_<uuid…>value). - On top of it,
apps/web/src/server/lib/jwt.tsmints a short-lived access token — 15-minute TTL hardcoded asACCESS_TOKEN_TTL_SECONDSin that file (not env-configurable; edit the constant if you need a different window). HS256 JWT keyed offAUTH_SECRET, hand-rolled on Web Crypto (no dependency, works on every runtime), verified statelessly (no DB hit) inmiddleware/session.ts.
Sign-in responses carry:
{ accessToken, refreshToken, expiresIn, tokenType, token /* legacy */ }token is === refreshToken, kept so older opaque-bearer clients
don’t break.
Refresh: POST /api/t/<slug>/auth/token/refresh exchanges a refresh
token (JSON body refreshToken / legacy token, or
Authorization: Bearer) for a fresh access token.
middleware/session.ts accepts either shape on Authorization: Bearer for app-plane callers — JWT first (fast path via
verifyAccessToken), opaque app_sessions lookup as fallback.
Trade-off: a JWT stays valid for its full TTL even after the
refresh token is revoked. Revocation (deleting the app_sessions
row) takes effect within ≤ TTL.
The admin / control-plane better-auth instance (packages/auth) also
has the bearer plugin always on, so native admin clients can
authenticate with Authorization: Bearer <session-token> instead of
a cookie.
API keys (personal access keys)
Section titled “API keys (personal access keys)”The api_keys table stores hashed personal access keys. The
full key format is pak_<8-hex prefix>_<32-hex secret>. Only the
SHA-256 of the secret is stored; the plaintext is returned exactly
once on POST and is never retrievable.
Minting one
Section titled “Minting one”In the admin, Settings → API keys → New key. Over the API — as an admin, with a session cookie, because a key cannot mint itself the first time:
curl -X POST http://localhost:5173/api/api-keys \ -H 'content-type: application/json' -H 'Origin: http://localhost:5173' \ --cookie "$(your admin session cookie)" \ -d '{"name":"ci"}'{ "data": { "prefix": "pak_7d84242e", "name": "ci", "roleId": null, "secret": "pak_7d84242e_613c…8ed7" }, "warning": "Store this secret now. It cannot be retrieved later — only revoked." }secret is the whole key and appears only in this response. Use it as
Authorization: Bearer pak_…, or hand it to the CLI once:
bunx @backlex/cli login --url http://localhost:5173 --key pak_…| Route | Does |
|---|---|
POST /api/api-keys | Create. Body: name?, roleId?, expiresAt?. Returns the secret once. |
GET /api/api-keys | List your keys — prefixes and metadata, never the secret. |
DELETE /api/api-keys/{id} | Revoke immediately. |
GET /api/api-keys/available-roles | Roles this caller may bind with roleId. |
apps/web/src/server/services/api-keys.tshandles create / list / revoke / lookup.- Auth middleware (
middleware/session.ts) tries the better-auth cookie session first, then falls back toAuthorization: Bearer pak_.... A resolved key impersonates itsuser_idso the request inherits that user’s roles + permissions, and pins the request to the key’stenant_id. nameis optional on POST (a timestamped default is generated).expires_atis optional — lookup rejects expired keys.
Role scoping
Section titled “Role scoping”A key may set role_id. When present:
- The request resolves permissions against only that role (no
implicit
authenticated). - Resolution only succeeds while the owner still holds that role
(
loadRolesForUser/loadTenantRoleNamesboth gate on(user_roles ⋈ roles) WHERE role.id = key.role_id). A scoped key can never grant more than its owner currently has. - Creation rejects a
role_idthe owner doesn’t hold — admins included (grant yourself the role first if you want a narrow self-key).
GET /api/api-keys/available-roles lists roles the caller may bind:
admins get every workspace role; everyone else gets only roles they
hold. auth.apiKeyRoleId carries the scope through the middleware
chain (set in app.ts AppBindings and AuthSubject in
@backlex/core).
Email transports
Section titled “Email transports”The EmailAdapter interface lives in
packages/core/src/adapters/email.ts:
send({ to, subject, text, html?, from? }): Promise<void>Implementations live in
apps/web/src/server/adapters/email.{console,resend,sendgrid,mailgun,ses,smtp}.ts.
Env-level selection
Section titled “Env-level selection”apps/web/src/server/lib/email-select.ts is the single place
transports get wired. It exports:
EmailSpec— the normalized union both layers below compile to.buildEmailAdapter(spec)— turns a spec into an adapter.selectEmailAdapter(env)— picks the deployment transport.
selectEmailAdapter(env) rules:
- If
EMAIL_PROVIDERis set (console | resend | sendgrid | mailgun | ses | smtp), use it. - Otherwise auto-detect from whichever provider has complete
credentials. Priority:
resend → sendgrid → mailgun → ses → smtp. - Otherwise fall back to the
consoleadapter (logs to stdout — fine for dev).
Every provider also needs EMAIL_FROM. The HTTP providers
(resend / sendgrid / mailgun / ses) work on every runtime —
SES is SigV4-signed via aws4fetch.
smtp is nodemailer-based and only works off Cloudflare Workers
(no raw TCP). The Worker bundle aliases nodemailer to a throwing
stub (shims/nodemailer-shim.ts, wired in both wrangler.toml [alias] and vite.config.ts); buildEmailAdapter skips smtp on
Workers with a warning.
Per-workspace email
Section titled “Per-workspace email”The email_config table holds workspace-level overrides:
- PK is
tenant_id, or the_globalsentinel for the instance-wide override row. - Columns:
provider,from_address,config(jsonb of non-secret knobs),secrets(jsonb ofenc:v1:…ciphertext, AES-256-GCM vialib/crypto, keyed offAUTH_SECRET— same scheme asauth_config.clientSecretEnc).
Resolution order: workspace row → _global row →
selectEmailAdapter(env). A row with provider = "inherit" (or a
blank/incomplete row) falls through.
Files:
services/email-config.ts—loadEmailConfigRow,resolveEmailAdapter. Mirrorsservices/auth-config.ts.routes/email-config.ts— admin-onlyGET/PUT/POST /test. Secrets are write-only;GETreturns onlysecretsSetflags. APUTinvalidatesgetTenantAuth’s cache viainvalidateTenantAuth.- Reads degrade to the env-default if the table isn’t migrated yet.
How to send mail in code
Section titled “How to send mail in code”Always send via ctx.emailFor(tenantId) (memoized per request) —
not ctx.email. ctx.email is the env-derived deployment default
that emailFor ends at; use it only for system mail with no
workspace context.
Already routed through emailFor:
sendTemplatedEmailgetTenantAuth(end-user verification / magic-link / OTP / reset mail)- The Functions sandbox
email.sendRPC - Workspace-invite mailers
Never reach for an email SDK directly.
OAuth providers
Section titled “OAuth providers”OAuth wiring is env-level (deployment default) with per-workspace
overrides through auth_config.socialProviders.
Env vars: OAUTH_GOOGLE_CLIENT_ID/SECRET,
OAUTH_GITHUB_CLIENT_ID/SECRET, OAUTH_APPLE_CLIENT_ID/SECRET.
- Apple’s
_CLIENT_IDis the Service ID;_CLIENT_SECRETis the JWT signed with the Apple key. - If both id + secret are present for a provider, it’s wired into
better-auth’s
socialProvidersautomatically. - A workspace with a configured provider in
auth_config.socialProviderstakes precedence over env-level wiring (seeservices/auth-config.ts).
Endpoints follow better-auth conventions:
/api/auth/sign-in/social/api/auth/callback/<provider>
When adding a new auth surface, prefer extending the better-auth
plugin set (passes through databaseHooks so the first-user-becomes-admin
logic still applies) over rolling a parallel sign-in flow.