Identity
Flametrench identity covers how a user is represented, how they authenticate, and how authenticated sessions are tracked.
Three entities make up identity:
usr_— a user: the human or service principal whose identity is being managed.cred_— a credential: one specific way the user can prove they are the user.ses_— a session: an authenticated link between a user and an application.
This chapter is normative. Decisions recorded here are requirements for conformance. Rationale and alternatives considered live in ADR 0004 — Identity model.
Users
Entity shape
A user is an opaque identity. The user entity carries:
id— UUIDv7; rendered on the wire asusr_<hex>.status— one ofactive,suspended,revoked.display_name— optional human-meaningful render string. Nullable. Added in v0.2 (ADR 0014). See "Display name" below.created_at— timestamp of creation, timezone-aware.updated_at— timestamp of last modification, timezone-aware.
Implementations MUST NOT require additional fields on usr_ for spec conformance. Identifiers (email, phone, handle) are application-layer extensions and live on cred_ rows.
Lifecycle
active → suspended → active (reinstate)
active → revoked (terminal)
suspended → revoked (terminal)
active— user can authenticate and participate.suspended— user cannot authenticate; active sessions MUST be terminated when the transition happens. The user's memberships (mem_) are NOT affected by user-level suspension; if the intent is to suspend a specific membership, usemem.statusinstead.revoked— user is terminated. All sessions MUST be terminated. All active credentials MUST transition torevoked. The user row is preserved for audit; it is never deleted.
Operations
Implementations MUST provide:
createUser(*, display_name?) → usr_id— returns a newusr_withstatus = active. The optionaldisplay_nameparameter defaults to null.getUser(usr_id) → user— returns the entity or a not-found error.updateUser(usr_id, *, display_name?) → user— partial-update operation (v0.2, ADR 0014). An omitted field means "no change"; an explicitnullmeans "set to null." Suspended users MAY be updated; revoked users MUST NOT (raisesAlreadyTerminalError).listUsers(*, cursor?, limit = 50, query?, status?) → Page<User>— cursor-paginated enumeration (v0.2, ADR 0015).queryis a case-insensitive substring filter against active credential identifiers;statusfilters by user status. Adopters MUST gate this call site (sysadmin route or equivalent); the SDK does not enforce authorization.suspendUser(usr_id)— transitions tosuspendedand terminates sessions.reinstateUser(usr_id)— transitionssuspended → active.revokeUser(usr_id)— transitions torevoked; triggers the cascade (sessions terminated, credentials revoked).
Identifiers
A user has no intrinsic identifier. Identifiers live on credentials. To find a user by email:
findCredentialByIdentifier(type = "password", identifier = "alice@example.com") → cred
cred.usr_id → the user
Applications MAY cache a denormalized email on their own usr_ extensions. The spec does not define this.
Display name (v0.2)
display_name is an optional render string for adopter UIs. The spec is intentionally permissive — no length cap, no uniqueness constraint, no required normalization — so adopters can layer their own rules at the application boundary. Two users may share a display name; users are identified by usr_id, not by display name.
SHOULD be set when the user has a human-meaningful identity rendered in adopter chrome ("Welcome, Nate"), user-list rows, mention surfaces, or audit logs. Without it, adopters fall back to rendering the credential identifier — which fails for passkey-only users (the credential ID is opaque), OIDC users (the IdP sub is opaque), and users with multiple credentials (no deterministic primary).
identity_store.update_user(usr_id, display_name="Alice Liddell")
# Clear it:
identity_store.update_user(usr_id, display_name=None)
# No-op (field unchanged):
identity_store.update_user(usr_id)Suspended users MAY be renamed. Revoked users MUST NOT — updateUser raises AlreadyTerminalError.
Authorization is unaffected by display-name changes. Tuples reference usr.id, never usr.display_name.
User enumeration (v0.2)
page = identity_store.list_users(
identifier_query="alice@", # matches any active credential identifier
status="active",
cursor=None, limit=50,
)
# page.data: list[User]; page.next_cursor: str | NoneOrdering is by id ascending (UUIDv7 creation order). Pagination uses seek-based cursors, matching the listMembers shape.
Credentials
Entity shape
A credential carries:
id— UUIDv7;cred_<hex>.usr_id— the user this credential authenticates.type— one ofpassword,passkey,oidc.identifier— a human-meaningful handle; format depends on type (see below).status— one ofactive,suspended,revoked.replaces— nullable FK to the previous credential in a rotation chain.- Type-specific payload (below).
created_at,updated_at.
Credential types
Password
identifier: login handle, typically an email.- Payload:
password_hash— an Argon2id PHC string.
Hashing requirements. Implementations MUST use Argon2id. The PHC-encoded hash MUST include parameters meeting or exceeding:
- Memory: 19 MiB (
m=19456) - Iterations: 2 (
t=2) - Parallelism: 1 (
p=1)
These match the OWASP Password Storage Cheat Sheet floor as of 2026. Implementations SHOULD use stronger parameters when hardware supports them.
Implementations MUST NOT store password credentials using bcrypt, scrypt, PBKDF2, SHA-2, or any other algorithm.
Passkey
identifier: the WebAuthn credential ID, base64url-encoded.- Payload:
passkey_public_key— raw public key bytes.passkey_sign_count— WebAuthn signature counter; incremented on each successful assertion.passkey_rp_id— the relying party ID (typically the application's eTLD+1).
OIDC
identifier: the issuer-assigned subject or email claim; application choice.- Payload:
oidc_issuer— the OIDC issuer URL.oidc_subject— the value of thesubclaim.
The pair (oidc_issuer, oidc_subject) uniquely identifies the user at the external identity provider.
At most one active credential per (type, identifier)
A user MAY have many credentials, but the pair (type, identifier) MUST be unique across active credentials. Historical revoked credentials with the same (type, identifier) are permitted — this allows a password to be rotated and the email to be reused under the new credential.
Lifecycle
active → suspended → active (reinstate)
active → revoked (terminal)
suspended → revoked (terminal)
suspended is for short-lived blocks (e.g., temporary lockout during a password reset). revoked is terminal.
Credential rotation — password change, passkey re-registration, OIDC re-link — follows the revoke-and-re-add pattern defined in ADR 0005:
- The existing credential transitions to
status = revoked. - A new credential is inserted with
replaces = old.id. - All sessions (
ses_) that were established by the rotated credential MUST be terminated (revoked_at = now()).
All three happen in one transaction.
Operations
createCredential(usr_id, type, identifier, payload) → cred_id.rotateCredential(cred_id, new_payload) → new_cred_id— implements the revoke-and-re-add sequence above.suspendCredential(cred_id),reinstateCredential(cred_id),revokeCredential(cred_id).verifyCredential(type, identifier, proof) → usr_id | null— verifies a proof (password, WebAuthn assertion, OIDC ID token) and returns the authenticated user. Entry point for session creation.
Sessions
Entity shape
A session is an authenticated session of a user:
id— UUIDv7;ses_<hex>.usr_id— the authenticated user.cred_id— the credential that established this session.created_at— timestamp of creation.expires_at— timestamp after which the session is considered expired.revoked_at— nullable; if set, the session is revoked.mfa_verified_at— nullable; timestamp of the most recent MFA verification on this session (v0.2). Used for step-up auth freshness checks.
The spec does NOT require IP address, user agent, device fingerprint, or geolocation. These are useful for security telemetry but carry PII and jurisdictional concerns; applications MAY add them as extension fields.
Sessions are user-bound
A session is bound to a usr_, not to an org_. A user with memberships in multiple orgs uses the same session across all of them; "active org" is a client-side context attribute.
Applications with stricter compliance requirements MAY enforce org-bound sessions at the application layer by gating each cross-org request with check() against the current org. The spec does not mandate this.
Rotation on refresh
Refreshing a session MUST create a new ses_ with a new id and mark the previous session revoked_at = now(). In-place refresh — keeping the same id and extending expires_at — is NOT spec-conformant.
Session ID versus session token
The session's id is not a secret. It appears in logs, admin panels, and audit queries. An SDK MUST NOT expose ses.id as a bearer credential.
The session's token is a separate piece of state derived from id plus implementation-specific signing or lookup. Both signed (JWT-style) and opaque (lookup table) patterns are conformant.
Termination
A session terminates when any of the following occur:
expires_atpasses.revokeSession(ses_id)is called.- The underlying credential is rotated or revoked (cascades to all sessions bound to it).
- The user is suspended or revoked (cascades to all their sessions).
Operations
createSession(usr_id, cred_id, ttl) → ses_id, token.refreshSession(ses_id) → new_ses_id, new_token.revokeSession(ses_id).listSessions(usr_id) → [session].
Multi-factor authentication (v0.2)
MFA ships as first-class in v0.2 (ADRs 0008 + 0010). Three factor types under the mfa_ ID prefix:
- TOTP (RFC 6238) — SHA-1/SHA-256/SHA-512. Standard test vectors pinned in conformance.
- WebAuthn assertion — ES256, RS256 (≥2048-bit), and EdDSA (Ed25519). Dispatched from the COSE_Key
algfield. Signature counter monotonicity enforced per WebAuthn spec §6.1.1 for cloned-authenticator detection. - Recovery codes — 10 single-use codes, each 12 characters long (format
XXXX-XXXX-XXXX), drawn from a 31-character alphabet excluding0/O/1/I/L.
MFA policy
usr_mfa_policy is a per-user enforcement record. When policy is active and the grace window has elapsed, verifyPassword surfaces mfa_required = true on the returned VerifiedCredential. Applications MUST check this flag and route the user to an MFA challenge before minting a session.
Session-mint flow with MFA
MFA does not mint sessions itself. The application sequences three calls:
# 1. Verify primary credential
result = identity_store.verify_password(identifier, candidate)
# result.mfa_required is True when policy is active and grace window elapsed
# 2. If MFA required, verify a factor
if result.mfa_required:
mfa_ok = identity_store.verify_mfa(usr_id, proof)
# 3. Mint the session
session = identity_store.create_session(usr_id, cred_id)MFA operations
enrollMfaFactor(usr_id, type, payload) → mfa_id— registers a TOTP, WebAuthn, or recovery-code factor. TOTP and WebAuthn require a subsequent confirmation step.confirmMfaFactor(mfa_id, proof) → mfa— confirms a pending factor (TOTP code or WebAuthn assertion).revokeMfaFactor(mfa_id).listMfaFactors(usr_id) → [mfa].verifyMfa(usr_id, proof) → bool— verifies a presented factor proof.getMfaPolicy(usr_id) → UserMfaPolicy.setMfaPolicy(usr_id, policy).
TOTP helpers
secret = identity_store.generate_totp_secret()
uri = identity_store.totp_otpauth_uri(secret, issuer="MyApp", account=email)
# Display uri as a QR code for the user to scanRecovery codes
Recovery codes are generated as a set and stored hashed. Each code is 12 characters in XXXX-XXXX-XXXX format and is single-use; the store marks it consumed on verify. When a user runs out of codes, issue a new set via enrollMfaFactor(usr_id, type="recovery").
Personal access tokens (v0.3)
PATs provide non-interactive, long-lived authentication for CLI tools, CI pipelines, and server-to-server calls. They are an alternative to sessions for contexts where browser-based login is unavailable or impractical.
Rationale lives in ADR 0016 — Personal access tokens.
Wire format
PAT bearer tokens use a two-part format:
pat_<32hex-id>_<base64url-secret>
The pat_<32hex> portion is the stored ID. The _<base64url-secret> suffix is the possession proof — it is the only time the secret is observable; the store persists only its Argon2id hash (at the same parameter floor as password credentials).
The SDK exports isStructurallyValidPatToken(token) for adopters that want to pre-screen bearers before dispatch. It validates the regex pattern only — pat_<32hex>_<base64url-chars> — with no cap on the secret length. A token with a 300-character secret passes the structural check; it will be rejected at verify-time by the 256-character cap (see step 2 below).
Bearer routing
The authentication middleware checks the prefix of the incoming bearer token and routes to the appropriate verifier:
ses_→ session verifiershr_→ share-token verifierpat_→ PAT verifier
This prevents cross-type token substitution without per-endpoint configuration.
Verification semantics (normative ordering)
- Parse the token into
(id_part, secret_part)at the second_(the base64url secret component may itself contain_characters; splitting on the last_would produce an incorrect boundary). - If
len(secret_part) > 256, raiseInvalidPatTokenError. This cap is a DoS defense — it prevents a malicious client from passing an arbitrarily large string through Argon2id. Real PAT secrets are 43 characters (32 random bytes, base64url-encoded); 256 leaves a large margin for non-reference implementations. - Look up the row by
pat_id. On any path that would skip the real Argon2id verify (row absent, id not a valid UUIDv7, or over-cap secret from step 2), run a dummy Argon2id verification against the spec-pinned hash (ADR 0023 /security.md) to keep all branches constant-time, then raiseInvalidPatTokenError. Every conforming SDK MUST use the same pinned hash string — a self-generated hash with different parameters reintroduces the timing oracle. The conflated error shape prevents a timing oracle on token presence. - If
revoked_atis non-null, raisePatRevokedError. - If
expires_atis set andexpires_at <= now, raisePatExpiredError. - Argon2id-verify
secret_partagainst the storedsecret_hash. If mismatch, raiseInvalidPatTokenError. - Set
last_used_at = now(best-effort; non-transactional). - Return the verified
usr_id.
Lifecycle
active → revoked (terminal; no suspend)
PATs have no suspend state. Scoping is via the scope TEXT[] field — applications define the scope values; the SDK records them but does not evaluate them (application enforces at the call site).
Operations
createPat(usr_id, name, scope?, expires_at?) → { pat, token }— mints a new PAT.tokenis returned once; store it securely.listPats(usr_id) → [pat]— lists all non-revoked PATs for a user.getPat(pat_id) → pat.revokePat(pat_id).
auth.kind discriminator
Audit records from v0.3 carry an auth.kind field distinguishing the authentication method:
auth.kind | Source |
|---|---|
session | A ses_ bearer token |
share | A shr_ share-token verify |
pat | A pat_ personal access token |
system | Automation with no human principal (no usr_id) |
Applications SHOULD log this field alongside the usr_id for audit completeness.
Conformance fixtures
The following fixtures are REQUIRED for identity interoperability across conforming implementations.
Argon2id hash format
A password credential created with the input password "correcthorsebatterystaple" and parameters m=19456, t=2, p=1 and the fixed salt c29tZXNhbHQxMjM (base64: "somesalt123") MUST produce a hash that verifies under any other compliant Argon2id verifier.
OIDC subject resolution
Given an OIDC credential with issuer = "https://accounts.google.com" and subject = "1234567890", findCredentialByIdentifier with the matching (issuer, subject) pair MUST return this credential. Issuer URL normalization follows RFC 3986: trailing-slash equivalence is permitted, but case normalization of the host is required.
MFA TOTP vectors (v0.2)
18 RFC 6238 §B test vectors are pinned in spec/conformance/fixtures/identity/mfa/totp-rfc6238.json. Every SDK MUST pass all 18.
MFA recovery-code format (v0.2)
Recovery codes MUST be 12 characters in XXXX-XXXX-XXXX format, drawn from a 31-character alphabet (excluding 0/O/1/I/L). 12 fixture tests in spec/conformance/fixtures/identity/mfa/recovery-code-format.json.
WebAuthn assertion (v0.2 + v0.3)
7 ES256 + 6 algorithm-dispatch (RS256, EdDSA) tests in spec/conformance/fixtures/identity/mfa/webauthn-assertion.json and webauthn-assertion-algorithms.json. Counter-decrease rejection: 4 tests.
PAT token format (v0.3)
11 structural-validation tests in spec/conformance/fixtures/identity/pat/token-format.json pinning the pat_<32hex>_<base64url> wire-format rules.
More conformance fixtures will be added in spec/conformance/ as implementations surface specific interoperability questions.