Authentication & identity
Every call has an actor: a signed-in person, a background job, later a machine client. This page answers what the identity looks like, how it reaches a service, and who proves it.
Model the caller as a tagged union of ids
Section titled “Model the caller as a tagged union of ids”Impact: HIGH the identity is never stale and never lies
- Day 1 is
Principal = User | System, aSchema.TaggedUnion.Principal.matchmakes every consumer handle every case, so addingApiKeylater makes the compiler list each site. - The principal carries ids and nothing else. Roles and scopes belong to authorization, which resolves them from these ids. The active organisation belongs to multi-tenancy. Profile fields are data you fetch.
System.jobis a closed literal list. A new background caller is a new literal, never a fake user. The deferredApiKeyvariant carries itsorgId, because a key is minted in one org.
❌ Incorrect — a flat struct with permission data that goes stale or lies:
export const CurrentUser = Schema.Struct({ userId: UserId, role: Schema.String, // roles are per org… organizationId: Schema.NullOr(OrgId), // …and this is null at every construction site authMode: Schema.String, // stamped the same for keys and sessions})✅ Correct — a union of ids, in packages/domain:
export const Principal = Schema.TaggedUnion({ User: { userId: UserId, sessionId: SessionId }, System: { job: Schema.Literals(["labeling/nightly-revalidate"]) },})export type Principal = typeof Principal.Type
export class CurrentPrincipal extends Context.Service<CurrentPrincipal, Principal>()( "@app/domain/CurrentPrincipal",) {}Source: notes/12-security-and-trust/authentication-and-identity.md · Decision 1, amended
Read CurrentPrincipal from R; provide it only at entry points
Section titled “Read CurrentPrincipal from R; provide it only at entry points”Impact: HIGH a caller that forgets who is acting does not compile
CurrentPrincipalis provided per request (HTTP middleware), per RPC call (anRpcMiddleware.Servicewithprovides: CurrentPrincipal), per job run or per queued message, always withEffect.provideService. It is never put in an applicationLayer, which would make every request share one actor. Tests are the only exception.- A non-request caller is a
Systemprincipal, never a fake user. Jobs and workflows carry the principal in their payload as an encodedPrincipal; a request’s principal never leaks into a forked or queued effect. - On the RPC socket, the upgrade is authenticated once, and
RpcAuthenticationprovides the principal per call. See RPC contracts.
❌ Incorrect — an actor taken from the caller, or a fake user for a job:
rules.create(payload, payload.actor) // trusts a value from the request body
rules.revalidate(ruleId).pipe( Effect.provideService(CurrentUser, { userId: botUserId }), // impersonation)✅ Correct — the requirement is in the signature; a job names itself:
readonly create: (input: CreateLabelingRuleInput) => Effect.Effect<Rule, Authz.Forbidden | RuleInvalid, CurrentPrincipal>
rules.revalidate(ruleId).pipe( Effect.provideService( CurrentPrincipal, Principal.cases.System.make({ job: "labeling/nightly-revalidate" }), ),)Source: notes/12-security-and-trust/authentication-and-identity.md · Decision 2, amended
Prove humans with an external IdP, behind one Authenticator
Section titled “Prove humans with an external IdP, behind one Authenticator”Impact: HIGH login, MFA and resets are a security product of their own
- The middleware verifies the IdP’s JWT (
Authorization: Bearer) on every request: signature via JWKS,exp,aud,iss. It mapssubto a local user row byexternalIdand providesPrincipal.User { userId, sessionId }. All vendor code sits behindAuthenticator. - Reject the empty credential explicitly: a missing header decodes to
Redacted.make(""), not a failure. Never log the credential. An unreachable IdP is a 503AuthenticationUnavailable; a bad token is a 401Unauthenticated. - The verifier is given “now”:
currentDatefromDateTime.nowwith a 60-secondclockTolerance, soTestClockdrives expiry tests. An unknownsubcreates the user row on first sight, with a UUIDv7UserIdfromnewId; the IdP’s subject is never our primary key.
❌ Incorrect — the vendor SDK in a feature, and the wall clock in the check:
import { verifyToken } from "@vendor/sdk" // vendor code outside identity/const claims = await verifyToken(token) // Date.now() inside; TestClock cannot drive it✅ Correct — one service, one shape:
export type AuthenticatorShape = { readonly authenticate: (token: Redacted.Redacted<string>) => Effect.Effect<Principal, Unauthenticated | AuthenticationUnavailable>}Source: notes/12-security-and-trust/authentication-and-identity.md · Decision 3, amended
Use one bearer middleware; dispatch API keys on a fixed prefix
Section titled “Use one bearer middleware; dispatch API keys on a fixed prefix”Impact: MEDIUM an unambiguous kind, matchable by secret scanners
- One
Authenticationmiddleware declaressecurity: { bearer: HttpApiSecurity.bearer }. On day 1 a non-empty token goes straight toauthenticator.authenticate. - When API keys land: dispatch on the fixed
app_ak_prefix only, never on a token’s shape. Storesha256(raw)with a unique index andlast4for display. Reject revoked and expired keys in the lookup. Hash inApiKeysonly. Annotate the scheme withHttpApiSecurity.annotate. - Details of issuance and display-once live in secrets, encryption and tokens.
❌ Incorrect — guessing the credential kind from its shape:
const isJwt = token.split(".").length === 3 // breaks when either format changes✅ Correct — day 1, one path, the empty credential refused:
export const layer = Layer.effect(Authentication, Effect.gen(function* () { const authenticator = yield* Authenticator.Authenticator return { bearer: Effect.fnUntraced(function* (httpEffect, { credential }) { if (Redacted.value(credential) === "") return yield* new Unauthenticated() const principal = yield* authenticator.authenticate(credential) return yield* Effect.provideService(httpEffect, CurrentPrincipal, principal) }), }}))Source: notes/12-security-and-trust/authentication-and-identity.md · Decision 4
Keep “who are you?” apart from “you may not”
Section titled “Keep “who are you?” apart from “you may not””Impact: MEDIUM a client can tell “sign in again” from “ask an admin”
Unauthenticated(401) is its own class, apart from the 403 that authorization defines.- The pieces live where HttpApi puts them:
| piece | location |
|---|---|
Principal, CurrentPrincipal |
packages/domain |
Authentication tag, Unauthenticated (401), AuthenticationUnavailable (503) |
packages/contracts |
Authenticator, ApiKeys (deferred), the middleware’s layer |
apps/server/src/identity/ |
| middleware layer provided | assembly boundary (apps/server/src/http.ts) |
❌ Incorrect — one error for both questions:
class UnauthorizedError extends Schema.TaggedError<UnauthorizedError>()("UnauthorizedError", {}) {}// thrown for a bad token and for a missing permission alike✅ Correct — two classes, two statuses:
Unauthenticated 401 who are you? → sign in againAuthz.Forbidden 403 you may not → ask an adminAuthenticationUnavailable 503 IdP unreachable → retrySource: notes/12-security-and-trust/authentication-and-identity.md · Decision 5
Deferred
Section titled “Deferred”- The
ApiKeyprincipal variant and the API-key subsystem (prefix dispatch,ApiKeys, its table and endpoints;OrgScopeaccepts a key only for its own org) — trigger: the first machine client. - Owned sessions or pairing — trigger: a self-hosted deployment, or a client with no browser.