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)
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 all four runtimes), 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)
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.
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
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
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
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
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
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
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.