effect-auth

Identity Management

Manage current-user login identities with atomic ownership and CAS checks.

An identity locates a stable user. It is not the user record, password credential, or necessarily a contact destination.

ObjectExamplePurpose
User01J...Stable account subject
Identityalice@example.com, alice, EMP-123456Normalized login locator
CredentialPassword hash, passkeyProof bound to a user
A safe email change adds and verifies a new identity before removing the old one

HTTP Operations

HTTP Operations preserve session-derived ownership and maintained mutation semantics while the application chooses routes and guards.

Identity Management is opt-in and absent from CoreAuthHttpApiLive. Every built-in operation validates a session. Listing and mutations derive userId from it; availability uses that user only as its limiter subject and performs an owner-independent uniqueness lookup.

Built-in contract

RouteRequestSuccess
POST /auth/identities/availabilityScope, kind, value, optional bot proof{ available }
GET /auth/identities/NoneActive identities
POST /auth/identities/addScope, kind, valueAdded identity
POST /auth/identities/replaceID, expectedUpdatedAt, next identityReplacement
POST /auth/identities/revokeID, expectedUpdatedAt, optional reasonRevoked snapshot
POST /auth/identities/primaryID, expectedUpdatedAtNew primary

Public results omit owner IDs, normalized values, metadata, replacement links, and revoked history. For tenant identities, derive tenant scope from trusted routing/session context or verify membership before invoking an operation; a browser-provided tenantId is not authorization.

Browser workflow

Use the same-origin unified client. Select the intended row explicitly and retain its CAS timestamp:

src/auth/change-username.ts
import { createAuthClient } from "@effect-auth/core/Client";

const auth = createAuthClient();

export async function changeUsername(nextUsername: string) {
  const { identities } = await auth.identities.list();
  const username = identities.find(
    (identity) => identity.kind === "username" && identity.isPrimaryLogin
  );

  if (username === undefined) throw new Error("No primary username");

  return auth.identities.replace({
    identityId: username.id,
    expectedUpdatedAt: username.updatedAt,
    scope: username.scope,
    kind: "username",
    value: nextUsername,
  });
}

createIdentityClient exposes the same six methods for a separately mounted contract. Availability is advisory; a concurrent mutation can win, so handle identity_already_registered from the atomic mutation.

Mutation lifecycle

  • Add/replace email creates an unverified identity; start ownership verification separately.
  • Replacing the last login identity with an unverified email is denied. Use add, verify, set-primary, revoke.
  • Username/custom kinds are locally login-eligible by default; policy must deny kinds requiring external proof.
  • Replace, revoke, and primary use expectedUpdatedAt to reject stale tabs.
  • The last login-eligible identity cannot be removed; primary must be active and login-eligible.
  • Revoked/replaced normalized values are immediately reusable unless app policy adds quarantine.
  • Replacement preserves primary status; replacing a non-primary row leaves the existing primary unchanged.

Maintained stores repeat owner, active-state, uniqueness, last-login, and CAS checks atomically. Service prechecks improve errors but do not replace database predicates.

Policy and consequences

IdentityManagementWithEmailAcceptanceLive applies EmailAcceptancePolicy only to email add/replace. IdentityMutationPolicyAllow always permits mutations; replace it for tenant policy, proofing, and assurance requirements. A configured mutation policy can deny with policy_denied, but does not emit step_up_required; enforce Step-up at the application HTTP boundary.

The built-in operations do not rotate/revoke sessions, start email verification, emit audit events, or send notifications. Add those consequences explicitly.

HTTP errors

CodeMeaning
bad_requestInvalid identity, stale CAS, inaccessible state
unauthenticatedMissing or invalid session
policy_deniedApp policy or last-login protection
identity_already_registeredActive normalized value already owned
rate_limitedStandard identity limit exceeded
internal_errorStorage, crypto, session, or risk-provider failure

Test tenant authorization, normalization, uniqueness races, stale CAS, cross-user IDs, final-login protection, email verification, public projection, and custom-store concurrency.

On this page