Skip to content

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.

Impact: HIGH the identity is never stale and never lies

  • Day 1 is Principal = User | System, a Schema.TaggedUnion. Principal.match makes every consumer handle every case, so adding ApiKey later 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.job is a closed literal list. A new background caller is a new literal, never a fake user. The deferred ApiKey variant carries its orgId, 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

  • CurrentPrincipal is provided per request (HTTP middleware), per RPC call (an RpcMiddleware.Service with provides: CurrentPrincipal), per job run or per queued message, always with Effect.provideService. It is never put in an application Layer, which would make every request share one actor. Tests are the only exception.
  • A non-request caller is a System principal, never a fake user. Jobs and workflows carry the principal in their payload as an encoded Principal; a request’s principal never leaks into a forked or queued effect.
  • On the RPC socket, the upgrade is authenticated once, and RpcAuthentication provides 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 maps sub to a local user row by externalId and provides Principal.User { userId, sessionId }. All vendor code sits behind Authenticator.
  • Reject the empty credential explicitly: a missing header decodes to Redacted.make(""), not a failure. Never log the credential. An unreachable IdP is a 503 AuthenticationUnavailable; a bad token is a 401 Unauthenticated.
  • The verifier is given “now”: currentDate from DateTime.now with a 60-second clockTolerance, so TestClock drives expiry tests. An unknown sub creates the user row on first sight, with a UUIDv7 UserId from newId; 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:

apps/server/src/identity/authenticator.ts
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 Authentication middleware declares security: { bearer: HttpApiSecurity.bearer }. On day 1 a non-empty token goes straight to authenticator.authenticate.
  • When API keys land: dispatch on the fixed app_ak_ prefix only, never on a token’s shape. Store sha256(raw) with a unique index and last4 for display. Reject revoked and expired keys in the lookup. Hash in ApiKeys only. Annotate the scheme with HttpApiSecurity.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 again
Authz.Forbidden 403 you may not → ask an admin
AuthenticationUnavailable 503 IdP unreachable → retry

Source: notes/12-security-and-trust/authentication-and-identity.md · Decision 5

  • The ApiKey principal variant and the API-key subsystem (prefix dispatch, ApiKeys, its table and endpoints; OrgScope accepts 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.