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.
| Object | Example | Purpose |
|---|---|---|
| User | 01J... | Stable account subject |
| Identity | alice@example.com, alice, EMP-123456 | Normalized login locator |
| Credential | Password hash, passkey | Proof bound to a user |
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
| Route | Request | Success |
|---|---|---|
POST /auth/identities/availability | Scope, kind, value, optional bot proof | { available } |
GET /auth/identities/ | None | Active identities |
POST /auth/identities/add | Scope, kind, value | Added identity |
POST /auth/identities/replace | ID, expectedUpdatedAt, next identity | Replacement |
POST /auth/identities/revoke | ID, expectedUpdatedAt, optional reason | Revoked snapshot |
POST /auth/identities/primary | ID, expectedUpdatedAt | New 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:
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
expectedUpdatedAtto 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
| Code | Meaning |
|---|---|
bad_request | Invalid identity, stale CAS, inaccessible state |
unauthenticated | Missing or invalid session |
policy_denied | App policy or last-login protection |
identity_already_registered | Active normalized value already owned |
rate_limited | Standard identity limit exceeded |
internal_error | Storage, 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.