Start HereOrdered learning track

User Identity Domain Model

Learn Java Authentication Pattern - Part 008

User Identity Domain Model untuk Java authentication system production-grade: account, person, subject, tenant, credential, authenticator, session, device, recovery, risk signal, audit event, lifecycle state, invariant, schema, dan service boundary.

13 min read2402 words
PrevNext
Lesson 0840 lesson track01–08 Start Here
#java#authentication#identity-domain-model#account-model+5 more

Part 008 — User Identity Domain Model

Target part ini: kita membangun model domain identity yang cukup kuat untuk mendukung password login, session, token, API key, OIDC federation, MFA, passkeys, recovery, tenant-aware auth, audit, dan incident response. Ini bukan sekadar tabel users(id,email,password).

Banyak sistem autentikasi gagal bukan karena library-nya jelek. Mereka gagal karena domain model-nya terlalu miskin.

Model buruk:

create table users (
  id bigint primary key,
  email varchar(255),
  password varchar(255),
  role varchar(50)
);

Itu cukup untuk demo. Untuk production, model ini lemah karena:

  • email dianggap identity permanen;
  • password disimpan satu kolom tanpa lifecycle;
  • tidak ada credential version;
  • tidak ada session model;
  • tidak ada status state machine;
  • tidak ada MFA/passkey;
  • role dicampur ke account;
  • tenant tidak dimodelkan;
  • recovery tidak terlihat;
  • audit tidak bisa menjawab “siapa melakukan apa, dengan assurance apa?”;
  • incident response tidak bisa revoke selektif.

Authentication system yang baik harus menjawab:

Siapa subjeknya?
Credential apa yang membuktikan subjek itu?
Authenticator apa yang terdaftar?
Session/token mana yang aktif?
Tenant mana yang sedang diakses?
Apa level assurance authentication saat ini?
Apakah identity itu user manusia, service account, device, atau external principal?
Bagaimana identity berubah sepanjang lifecycle?
Bagaimana kita revoke saat compromise?

1. Prinsip Utama

Identity domain model harus memisahkan hal-hal yang sering dicampur:

KonsepJangan dicampur dengan
PersonAccount login
AccountCredential
SubjectEmail/username
CredentialAuthenticator
SessionToken
Tenant membershipGlobal account
RolePermission
Login identifierImmutable subject id
Authentication eventBusiness audit event
Recovery channelMFA authenticator

Prinsip paling penting:

Identifier yang dipakai sebagai subject internal harus stable, opaque, dan tidak bergantung pada email/username.

Email bisa berubah. Username bisa rename. Nomor telepon bisa berpindah pemilik. External IdP subject bisa berbeda per client/tenant. Tapi subject_id internal harus stabil.


2. Mental Model: Identity Graph

Untuk sistem kecil, kamu melihat user sebagai row. Untuk sistem besar, identity adalah graph.

Bentuk graf ini membuat desain lebih tahan terhadap perubahan:

  • satu person bisa punya lebih dari satu account;
  • satu account bisa punya banyak identifier;
  • satu account bisa punya banyak authenticator;
  • satu account bisa menjadi member di banyak tenant;
  • satu session bisa punya assurance level tertentu;
  • satu credential bisa dirotasi tanpa mengganti subject;
  • satu external identity bisa linked/unlinked.

3. Core Aggregate

Dalam domain-driven design, jangan buru-buru membuat satu aggregate raksasa User.

Pisahkan boundary:

Aggregate / EntityFungsi
Accountlifecycle login identity manusia/service
Subjectcanonical authenticated identity
LoginIdentifieremail/username/phone/external alias untuk lookup
Credentialsecret/verifier yang membuktikan klaim
Authenticatorfaktor autentikasi terdaftar, misalnya TOTP/passkey
Sessionauthenticated continuity state
TenantMembershiphubungan account dengan tenant
RecoveryMethodchannel/prosedur recovery
DeviceBindingdevice/browser/client binding
AuthenticationEventimmutable evidence trail
RiskSignalsinyal untuk adaptive auth

Jika kamu menamai semuanya User, sistem akan cepat membusuk.


4. Account Bukan Person

Person adalah manusia di dunia nyata. Account adalah representasi login di sistem.

Kenapa dipisah?

ScenarioDampak jika dicampur
Employee punya admin account dan normal accountprivilege separation kacau
Customer merged dari dua sourcelogin identity ikut rusak
Support impersonationaudit tidak bisa bedakan actor vs target
Person resign tetapi account service tetap aktiflifecycle campur
Legal identity berubah nama/emailsubject berubah padahal audit harus stabil

Minimal model:

public record PersonId(UUID value) {}
public record AccountId(UUID value) {}
public record SubjectId(String value) {}

public final class Account {
    private final AccountId id;
    private final SubjectId subjectId;
    private final Optional<PersonId> personId;
    private AccountStatus status;
    private AccountType type;
    private long credentialVersion;
    private long privilegeVersion;

    // behavior, not just getters
}

AccountType:

public enum AccountType {
    HUMAN,
    SERVICE,
    ROBOT,
    BREAK_GLASS,
    EXTERNAL_FEDERATED
}

5. Subject: Canonical Identity

Subject adalah identity yang sudah dinormalisasi setelah autentikasi.

credential proof -> account -> subject

Subject internal sebaiknya:

  • opaque;
  • immutable;
  • globally unique atau tenant-scoped dengan namespace jelas;
  • tidak reusable setelah account delete;
  • tidak mengandung PII;
  • aman untuk audit log;
  • dapat direferensikan dari token/session/audit.

Contoh subject id:

sub_01J2K7H8EH7Z8T4S9PVK6CJ4C1
acct_01J2K7H8EH7Z8T4S9PVK6CJ4C1
human:01J2K7H8EH7Z8T4S9PVK6CJ4C1
service:billing-worker:prod

Yang buruk:

alice@example.com
+6281234567890
john.doe
42

Kenapa email buruk sebagai subject?

- email bisa berubah
- email bisa dipakai ulang oleh provider/domain tertentu
- email adalah PII
- email expose tenant/company
- email case/normalization kompleks
- email bisa menjadi login identifier, bukan subject identity

Invariant:

Once a subject_id appears in an audit event, it must continue to identify the same logical subject forever.

6. Account Lifecycle State Machine

Account bukan boolean enabled.

Production state:

Minimal enum:

public enum AccountStatus {
    PENDING_VERIFICATION,
    ACTIVE,
    LOCKED,
    SUSPENDED,
    RECOVERY_PENDING,
    DISABLED,
    DEPROVISIONED
}

Jangan semua status diperlakukan sama.

StatusLogin behaviorExternal responseInternal reason
PENDING_VERIFICATIONdeny or limitedgenericemail/phone unverified
ACTIVEallowsuccessvalid
LOCKEDdeny temporarilygenericfailed attempt threshold
SUSPENDEDdenygenericadmin/risk action
RECOVERY_PENDINGdeny or step-upgenericrecovery flow active
DISABLEDdenygenericuser/admin disabled
DEPROVISIONEDdeny permanentlygenericlifecycle ended

Invariant:

External login failure shape must not reveal account status.
Internal audit reason must preserve exact account status.

7. Login Identifier

Login identifier adalah nilai yang user/client pakai untuk menemukan account.

Jenis:

IdentifierContohRisiko
Emailalice@example.comPII, normalization, reuse
Usernamealiceenumeration, rename
Phone+62812...reassignment, SIM swap
External subjectOIDC iss+subprovider-specific
API key prefixak_live_xxxleakage correlation
Certificate fingerprintSHA-256 cert hashrotation

Model:

public record LoginIdentifierId(UUID value) {}

public final class LoginIdentifier {
    private final LoginIdentifierId id;
    private final AccountId accountId;
    private final LoginIdentifierType type;
    private final String normalizedValueHash;
    private final String displayValueEncrypted;
    private boolean verified;
    private Instant verifiedAt;
    private Instant createdAt;
}

public enum LoginIdentifierType {
    EMAIL,
    USERNAME,
    PHONE,
    OIDC_SUBJECT,
    SAML_NAME_ID,
    API_KEY_PREFIX,
    CERTIFICATE_FINGERPRINT
}

Kenapa normalizedValueHash?

  • lookup cepat tanpa menyimpan PII plaintext sebagai index utama;
  • bisa enforce uniqueness;
  • audit tidak selalu butuh expose value;
  • breach impact lebih kecil.

Namun jangan pura-pura hash email selalu aman. Email punya entropy rendah dan bisa didictionary attack. Untuk data sensitif, gunakan pepper/HMAC dengan secret server-side.

Example:

String normalized = EmailNormalizer.normalize(rawEmail);
String lookupKey = hmacSha256(appPepper, normalized);

DB:

create table login_identifier (
    id uuid primary key,
    account_id uuid not null references account(id),
    type varchar(40) not null,
    normalized_lookup_key bytea not null,
    display_value_ciphertext bytea,
    verified boolean not null default false,
    verified_at timestamptz,
    created_at timestamptz not null,
    unique (type, normalized_lookup_key)
);

8. Credential vs Authenticator

Credential adalah proof/verifier. Authenticator adalah faktor/metode yang user punya atau gunakan.

KonsepContoh
Credentialpassword hash, API key secret hash, private-key public key credential id, TOTP secret encrypted
Authenticatorpassword authenticator, passkey authenticator, TOTP app, hardware key, recovery code set

Untuk password, credential dan authenticator sering terlihat sama. Untuk passkey/WebAuthn, bedanya jelas:

Authenticator = security key / platform authenticator
Credential = public key credential registered on server

Model generik:

public record CredentialId(UUID value) {}
public record AuthenticatorId(UUID value) {}

public final class Authenticator {
    private final AuthenticatorId id;
    private final AccountId accountId;
    private final AuthenticatorType type;
    private AuthenticatorStatus status;
    private String label;
    private Instant enrolledAt;
    private Instant lastUsedAt;
}

public enum AuthenticatorType {
    PASSWORD,
    TOTP,
    WEBAUTHN_PASSKEY,
    SMS_OTP,
    EMAIL_OTP,
    RECOVERY_CODE,
    API_KEY,
    CLIENT_CERTIFICATE
}

Credential:

public final class Credential {
    private final CredentialId id;
    private final AccountId accountId;
    private final Optional<AuthenticatorId> authenticatorId;
    private final CredentialType type;
    private CredentialStatus status;
    private final String algorithm;
    private final int version;
    private final byte[] verifier;
    private Instant createdAt;
    private Instant rotatedAt;
    private Instant expiresAt;
}

public enum CredentialType {
    PASSWORD_HASH,
    API_KEY_HASH,
    TOTP_SECRET,
    WEBAUTHN_PUBLIC_KEY,
    RECOVERY_CODE_HASH,
    CLIENT_CERT_FINGERPRINT,
    FEDERATED_SUBJECT_LINK
}

Invariant:

Credential verification must never require plaintext secret stored server-side unless the protocol inherently requires a decryptable secret, such as TOTP.

Even for TOTP, encrypt secret at rest and isolate key management.


9. Password Credential Model

Password bukan satu kolom. Ia punya lifecycle.

Minimal fields:

create table password_credential (
    id uuid primary key,
    account_id uuid not null references account(id),
    status varchar(40) not null,
    password_hash text not null,
    algorithm varchar(40) not null,
    parameters jsonb not null,
    version integer not null,
    created_at timestamptz not null,
    last_verified_at timestamptz,
    rotated_at timestamptz,
    must_rotate boolean not null default false,
    compromised_detected_at timestamptz,
    retired_at timestamptz
);

Why:

FieldReason
algorithmsupport migration from PBKDF2/BCrypt to Argon2 etc
parameterscost factor/memory/time/version
versionoptimistic locking and audit
last_verified_atdetect dormant credentials
must_rotateforce reset after incident
compromised_detected_atbreach handling
retired_atforensic trail

Verification result should carry operational signal:

public sealed interface PasswordVerificationResult {
    record Valid(boolean rehashRecommended) implements PasswordVerificationResult {}
    record Invalid() implements PasswordVerificationResult {}
    record Locked() implements PasswordVerificationResult {}
    record MustRotate() implements PasswordVerificationResult {}
}

Do not expose these exact reasons to the login response.


10. Session Model

Session adalah continuity setelah authentication event.

Session bukan account. Session bukan token. Session adalah server-side atau logical state bahwa subject sudah authenticated dengan condition tertentu.

Fields:

create table auth_session (
    id uuid primary key,
    account_id uuid not null references account(id),
    subject_id varchar(80) not null,
    tenant_id uuid,
    status varchar(40) not null,
    assurance_level varchar(40) not null,
    authentication_method varchar(80) not null,
    authenticated_at timestamptz not null,
    last_seen_at timestamptz not null,
    expires_at timestamptz not null,
    absolute_expires_at timestamptz not null,
    privilege_version bigint not null,
    credential_version bigint not null,
    device_id uuid,
    ip_hash bytea,
    user_agent_hash bytea,
    revoked_at timestamptz,
    revocation_reason varchar(80)
);

Session status:

public enum SessionStatus {
    ACTIVE,
    IDLE_EXPIRED,
    ABSOLUTE_EXPIRED,
    LOGGED_OUT,
    REVOKED,
    REAUTH_REQUIRED,
    COMPROMISED
}

Important distinction:

ExpiryMeaning
Idle expiryno activity for duration
Absolute expirysession cannot live beyond max age
Step-up expiryhigh-assurance action window expired
Token expirybearer/reference token validity
Refresh expiryability to mint new access token

State machine:

Invariant:

Session must bind to subject, authentication method, assurance level, and privilege/credential version.

Why privilege_version?

If roles change, you can invalidate sessions whose privilege version is stale.

Why credential_version?

If password/MFA changes, you can revoke sessions minted with old credential state.


11. Token Model

Token may be JWT, opaque token, refresh token, remember-me token, API key, or recovery token. They are not the same.

Token typeHolderRevocation needStorage
Access JWTclientshort-lived; hard revocationusually not stored fully
Opaque access tokenclientintrospection/revocation easyhash/reference stored
Refresh tokenclientmust support rotation/reuse detectionhash stored
Remember-me tokenbrowsermust revoke on logout/security eventhash stored
Recovery tokenuser email/deviceone-time, short-livedhash stored
API keypartner/servicerotation and scopeprefix + hash

Generic token table:

create table auth_token (
    id uuid primary key,
    token_family_id uuid,
    account_id uuid references account(id),
    subject_id varchar(80) not null,
    client_id varchar(120),
    token_type varchar(40) not null,
    token_lookup_key bytea not null,
    token_hash bytea not null,
    status varchar(40) not null,
    scope text[] not null default '{}',
    audience text[] not null default '{}',
    issued_at timestamptz not null,
    expires_at timestamptz not null,
    last_used_at timestamptz,
    rotated_from uuid,
    revoked_at timestamptz,
    revocation_reason varchar(80),
    unique (token_lookup_key)
);

Never store bearer token plaintext.

Refresh token family model:

Invariant:

A refresh token that has been rotated must not be accepted again.
Reuse means possible theft and should revoke the token family.

12. Tenant Membership Model

Multi-tenant auth fails when tenant is treated as request parameter only.

Wrong:

GET /tenant/{tenantId}/cases
Authorization: Bearer token for account A

Controller trusts tenantId from path.

Correct:

tenantId from request must be checked against subject membership and token/session claims.

Model:

create table tenant_membership (
    id uuid primary key,
    tenant_id uuid not null references tenant(id),
    account_id uuid not null references account(id),
    status varchar(40) not null,
    membership_type varchar(40) not null,
    role_version bigint not null,
    joined_at timestamptz not null,
    suspended_at timestamptz,
    unique (tenant_id, account_id)
);

create table tenant_role_assignment (
    id uuid primary key,
    membership_id uuid not null references tenant_membership(id),
    role_key varchar(120) not null,
    assigned_at timestamptz not null,
    assigned_by_subject_id varchar(80)
);

State:

public enum TenantMembershipStatus {
    INVITED,
    ACTIVE,
    SUSPENDED,
    REMOVED
}

Tenant resolution pipeline:

Invariant:

A tenant hint is not tenant authorization.

OIDC/SAML federation introduces external identity.

Do not replace internal subject with raw external claims.

Model:

create table external_identity_link (
    id uuid primary key,
    account_id uuid not null references account(id),
    provider_key varchar(120) not null,
    issuer varchar(500) not null,
    external_subject varchar(500) not null,
    external_subject_lookup_key bytea not null,
    status varchar(40) not null,
    linked_at timestamptz not null,
    last_login_at timestamptz,
    unique (provider_key, issuer, external_subject_lookup_key)
);

Why issuer + subject?

In OIDC, sub is meaningful under an issuer. A sub alone is not globally meaningful.

Flow:

Invariant:

External identity proves a link. Application audit uses internal subject.

14. MFA / Authenticator Enrollment Model

MFA is not a boolean mfa_enabled.

You need:

  • which authenticators are enrolled;
  • which are verified;
  • which are usable for login vs step-up;
  • backup/recovery method;
  • last used timestamp;
  • compromised/revoked state;
  • enrollment assurance;
  • factor type.

Table:

create table authenticator (
    id uuid primary key,
    account_id uuid not null references account(id),
    type varchar(40) not null,
    status varchar(40) not null,
    label varchar(120),
    verified boolean not null default false,
    enrolled_at timestamptz not null,
    verified_at timestamptz,
    last_used_at timestamptz,
    revoked_at timestamptz,
    metadata jsonb not null default '{}'
);

Specific tables:

create table totp_authenticator_secret (
    authenticator_id uuid primary key references authenticator(id),
    secret_ciphertext bytea not null,
    secret_key_id varchar(120) not null,
    period_seconds integer not null,
    digits integer not null,
    algorithm varchar(20) not null
);

create table webauthn_credential (
    authenticator_id uuid not null references authenticator(id),
    credential_id bytea primary key,
    public_key_cose bytea not null,
    sign_count bigint,
    transports text[] not null default '{}',
    backup_eligible boolean,
    backup_state boolean,
    attestation_type varchar(80),
    aaguid uuid
);

MFA enrollment state:

Invariant:

An authenticator is not trusted until enrollment proof is verified.

15. Recovery Model

Recovery is often the weakest part of authentication.

Do not model recovery as just reset_token on user row.

Recovery needs:

  • request event;
  • channel;
  • token hash;
  • expiry;
  • one-time consumption;
  • risk evaluation;
  • session invalidation after success;
  • credential version increment;
  • audit chain.

Table:

create table account_recovery_request (
    id uuid primary key,
    account_id uuid references account(id),
    login_identifier_lookup_key bytea,
    status varchar(40) not null,
    channel varchar(40) not null,
    token_hash bytea not null,
    requested_at timestamptz not null,
    expires_at timestamptz not null,
    consumed_at timestamptz,
    ip_hash bytea,
    user_agent_hash bytea,
    risk_score integer,
    failure_reason varchar(80)
);

State machine:

Invariant:

Successful recovery should rotate the affected credential and revoke existing sessions unless policy explicitly says otherwise.

16. Device Model

Device model is a risk signal, not absolute identity.

A cookie/browser fingerprint/device id can help risk-based authentication, but it is not proof of identity by itself.

Table:

create table known_device (
    id uuid primary key,
    account_id uuid not null references account(id),
    device_binding_hash bytea not null,
    label varchar(120),
    first_seen_at timestamptz not null,
    last_seen_at timestamptz not null,
    last_ip_hash bytea,
    last_user_agent_hash bytea,
    status varchar(40) not null,
    trust_level varchar(40) not null,
    unique (account_id, device_binding_hash)
);

Device status:

public enum DeviceStatus {
    NEW,
    RECOGNIZED,
    TRUSTED,
    SUSPICIOUS,
    REVOKED
}

Rule:

Known device can reduce friction.
Known device must not bypass required authenticator for high-risk action.

17. Risk Signal Model

Risk signal should be append-only or at least auditable.

Examples:

SignalMeaning
failed login velocitybrute force / stuffing
impossible travelgeo anomaly
new deviceunknown client
ASN changenetwork anomaly
password breach matchcredential compromise
refresh token reusetoken theft
MFA fatigue patternpush bombing
recovery requested after password failtakeover attempt

Model:

create table auth_risk_signal (
    id uuid primary key,
    account_id uuid references account(id),
    subject_id varchar(80),
    tenant_id uuid,
    signal_type varchar(80) not null,
    severity varchar(40) not null,
    score integer not null,
    observed_at timestamptz not null,
    source varchar(80) not null,
    attributes jsonb not null default '{}'
);

Adaptive auth uses risk signals:

Invariant:

Risk signal influences authentication flow; it should not silently mutate account status without auditable decision.

18. Authentication Event Model

Authentication events are forensic evidence.

Do not rely only on application logs.

Event table / stream payload:

{
  "eventId": "evt_01J2...",
  "eventType": "AUTH_LOGIN_SUCCEEDED",
  "occurredAt": "2026-07-03T10:15:30Z",
  "subjectId": "sub_01J2...",
  "accountId": "acc_01J2...",
  "tenantId": "tenant_01J2...",
  "mechanism": "PASSWORD_SESSION",
  "assuranceLevel": "AAL2",
  "credentialId": "cred_01J2...",
  "authenticatorId": "authn_01J2...",
  "sessionId": "sess_01J2...",
  "clientId": "web-bff",
  "ipHash": "...",
  "userAgentHash": "...",
  "outcome": "SUCCESS",
  "reason": "VALID_PASSWORD_AND_TOTP",
  "correlationId": "req_01J2..."
}

Event types:

EventTrigger
AUTH_LOGIN_STARTEDlogin request begins
AUTH_LOGIN_SUCCEEDEDauthentication success
AUTH_LOGIN_FAILEDcredential/status failure
AUTH_STEP_UP_REQUIREDassurance insufficient
AUTH_STEP_UP_SUCCEEDEDMFA/passkey success
AUTH_SESSION_CREATEDsession minted
AUTH_SESSION_REVOKEDexplicit revocation
AUTH_PASSWORD_CHANGEDpassword rotation
AUTH_RECOVERY_REQUESTEDrecovery started
AUTH_RECOVERY_CONSUMEDrecovery token used
AUTH_API_KEY_CREATEDAPI key created
AUTH_API_KEY_REVOKEDAPI key revoked
AUTH_EXTERNAL_IDENTITY_LINKEDOIDC/SAML link created

Invariant:

Every security-relevant authentication state transition must produce an audit event.

19. Assurance Level in Domain Model

Authentication is not binary.

A session created by password-only login is not equivalent to:

  • password + TOTP;
  • passkey user verification;
  • enterprise OIDC with MFA;
  • service account mTLS;
  • recovery-token login.

Model:

public enum AuthenticationAssuranceLevel {
    AAL1,
    AAL2,
    AAL3,
    INTERNAL_SERVICE,
    RECOVERY_LIMITED,
    UNKNOWN
}

Do not overclaim NIST AAL unless your implementation satisfies the actual requirements. But as internal model, assurance level is useful.

Use cases:

ActionRequired assurance
View own profilepassword/session acceptable
Change emailrecent auth + MFA
Change passwordrecent auth or recovery flow
Create API keyMFA/passkey step-up
Disable another useradmin role + high assurance
Export regulated datahigh assurance + tenant policy

Store on session:

public record AuthenticatedSession(
    SessionId id,
    SubjectId subjectId,
    TenantId tenantId,
    AuthenticationAssuranceLevel assuranceLevel,
    Set<AuthenticationMethod> methods,
    Instant authenticatedAt,
    Instant stepUpExpiresAt
) {}

Invariant:

Authorization for sensitive operations should check freshness and assurance, not only role.

20. Java Package Boundary

A maintainable Java auth system should not put everything in security.

Suggested packages:

com.example.identity.account
com.example.identity.credential
com.example.identity.authenticator
com.example.identity.session
com.example.identity.tenant
com.example.identity.recovery
com.example.identity.risk
com.example.identity.audit
com.example.identity.federation
com.example.identity.api
com.example.identity.infrastructure

Better:

identity-core
  Account, SubjectId, Credential, Authenticator, Session domain model

identity-application
  LoginService, RegisterCredentialService, RevokeSessionService

identity-adapters-jakarta
  HttpAuthenticationMechanism, IdentityStore, SecurityContext adapter

identity-adapters-spring
  AuthenticationProvider, SecurityFilterChain adapter

identity-infrastructure-postgres
  repositories, SQL mapping

identity-infrastructure-redis
  rate limit/session cache/token cache

identity-events
  audit event schema and publisher

Why separate adapters?

Because authentication domain should survive framework migration:

Spring Security today
Jakarta Security tomorrow
OIDC external IdP next year

The core invariants remain.


21. Application Services

Avoid placing all logic in filters/providers.

Core services:

ServiceResponsibility
LoginApplicationServiceorchestrates login attempt
CredentialVerificationServiceverifies credential and migration signal
AccountStatusPolicydecides account state behavior
SessionServicecreates/revokes/validates sessions
MfaChallengeServicecreates and verifies step-up challenges
RecoveryServicepassword/account recovery flow
ExternalIdentityLinkServicemaps IdP identity to account
RiskEvaluationServiceevaluates login context
AuthenticationAuditServiceemits immutable auth events

Login orchestration:

Command:

public record LoginAttemptCommand(
    TenantHint tenantHint,
    LoginIdentifierInput loginIdentifier,
    PresentedCredential credential,
    ClientContext clientContext,
    Instant attemptedAt,
    CorrelationId correlationId
) {}

Result:

public sealed interface LoginAttemptResult {
    record Success(SubjectId subjectId, SessionId sessionId, AuthenticationAssuranceLevel aal)
            implements LoginAttemptResult {}
    record StepUpRequired(SubjectId subjectId, ChallengeId challengeId)
            implements LoginAttemptResult {}
    record Failed(GenericFailureReason publicReason, InternalFailureReason internalReason)
            implements LoginAttemptResult {}
}

External adapter only sees what it needs to render HTTP response.


22. Database Schema: Production Skeleton

A pragmatic first schema:

create table account (
    id uuid primary key,
    subject_id varchar(80) not null unique,
    person_id uuid,
    type varchar(40) not null,
    status varchar(40) not null,
    credential_version bigint not null default 0,
    privilege_version bigint not null default 0,
    created_at timestamptz not null,
    updated_at timestamptz not null,
    deprovisioned_at timestamptz
);

create table login_identifier (
    id uuid primary key,
    account_id uuid not null references account(id),
    type varchar(40) not null,
    normalized_lookup_key bytea not null,
    display_value_ciphertext bytea,
    verified boolean not null default false,
    verified_at timestamptz,
    created_at timestamptz not null,
    unique (type, normalized_lookup_key)
);

create table credential (
    id uuid primary key,
    account_id uuid not null references account(id),
    authenticator_id uuid,
    type varchar(40) not null,
    status varchar(40) not null,
    algorithm varchar(80),
    parameters jsonb not null default '{}',
    verifier bytea not null,
    version integer not null,
    created_at timestamptz not null,
    last_verified_at timestamptz,
    rotated_at timestamptz,
    expires_at timestamptz,
    revoked_at timestamptz
);

create table authenticator (
    id uuid primary key,
    account_id uuid not null references account(id),
    type varchar(40) not null,
    status varchar(40) not null,
    label varchar(120),
    verified boolean not null default false,
    enrolled_at timestamptz not null,
    verified_at timestamptz,
    last_used_at timestamptz,
    revoked_at timestamptz,
    metadata jsonb not null default '{}'
);

create table auth_session (
    id uuid primary key,
    account_id uuid not null references account(id),
    subject_id varchar(80) not null,
    tenant_id uuid,
    status varchar(40) not null,
    assurance_level varchar(40) not null,
    authentication_method varchar(80) not null,
    authenticated_at timestamptz not null,
    last_seen_at timestamptz not null,
    expires_at timestamptz not null,
    absolute_expires_at timestamptz not null,
    privilege_version bigint not null,
    credential_version bigint not null,
    revoked_at timestamptz,
    revocation_reason varchar(80)
);

create table authentication_event (
    id uuid primary key,
    event_type varchar(80) not null,
    occurred_at timestamptz not null,
    account_id uuid,
    subject_id varchar(80),
    tenant_id uuid,
    mechanism varchar(80),
    assurance_level varchar(40),
    outcome varchar(40) not null,
    reason varchar(120),
    correlation_id varchar(120),
    attributes jsonb not null default '{}'
);

Indexes:

create index idx_account_subject on account(subject_id);
create index idx_login_identifier_lookup on login_identifier(type, normalized_lookup_key);
create index idx_credential_account_type_status on credential(account_id, type, status);
create index idx_session_subject_status on auth_session(subject_id, status);
create index idx_session_expires on auth_session(expires_at);
create index idx_auth_event_subject_time on authentication_event(subject_id, occurred_at desc);
create index idx_auth_event_type_time on authentication_event(event_type, occurred_at desc);

23. Invariant Catalogue

Use these as code review rules.

Identity

I-001: subject_id is stable and opaque.
I-002: login identifier is not subject identity.
I-003: deleted/deprovisioned subject_id is never reused.
I-004: account type is explicit: human, service, break-glass, external.

Credential

C-001: no bearer secret stored plaintext.
C-002: credential has status and version.
C-003: password hash algorithm and parameters are stored.
C-004: credential rotation increments account credential_version.
C-005: unsupported credential type is not treated as invalid proof by unrelated verifier.

Session

S-001: session binds subject_id, account_id, assurance level, credential_version, and privilege_version.
S-002: logout/revocation changes server-side state where possible.
S-003: role/privilege changes can invalidate stale sessions.
S-004: session cannot outlive absolute expiry.
S-005: step-up freshness is separately tracked.

Tenant

T-001: tenant hint is not tenant authorization.
T-002: subject membership must be checked before tenant-scoped operation.
T-003: cross-tenant identity links must be explicit.
T-004: audit event includes tenant when operation is tenant-scoped.

Audit

A-001: every auth state transition emits an event.
A-002: external error is generic; internal event has exact reason.
A-003: audit subject comes from canonical authenticated subject.
A-004: impersonation records actor and target separately.

24. Anti-Patterns

Anti-pattern 1: users.role

alter table users add column role varchar(30);

Problem:

  • cannot model multi-tenant roles;
  • cannot audit assignment;
  • cannot support multiple roles;
  • cannot version privilege;
  • cannot represent external group mapping.

Better:

tenant_membership -> role_assignment -> policy/permission

Anti-pattern 2: Email as Primary Key

Problem:

  • email changes;
  • PII leakage;
  • provider/domain normalization;
  • account recovery complications;
  • audit instability.

Better:

subject_id/account_id as immutable id
email as login_identifier

Anti-pattern 3: Boolean MFA

mfa_enabled boolean

Problem:

  • cannot know factor type;
  • cannot revoke one device;
  • cannot track enrollment verification;
  • cannot model passkeys;
  • cannot handle backup codes;
  • cannot assess assurance.

Better:

authenticator table + authenticator-specific credential table

Anti-pattern 4: Password Reset Token on User Row

Problem:

  • cannot audit multiple recovery attempts;
  • race conditions;
  • no token family/state;
  • weak incident response.

Better:

account_recovery_request as independent lifecycle object

Anti-pattern 5: Session Without Version

Problem:

User's admin role revoked, but old session still says admin.

Better:

session.privilege_version must match account.privilege_version

25. Implementation Path

Build in this order:

1. SubjectId and Account aggregate
2. LoginIdentifier model with normalization/lookup key
3. Credential model with password hash metadata
4. AuthenticationEvent append-only publisher
5. LoginApplicationService with generic external failure
6. Session model with credential/privilege version
7. TenantMembership model
8. Authenticator model for MFA/passkey
9. Recovery request model
10. Risk signal model

Do not start with JWT. Start with identity state.

Why?

A token is just a transport artifact.
The hard problem is the identity lifecycle behind it.

26. Exercises

Exercise 1 — Refactor Demo User Table

Given:

users(id, email, password, role, enabled)

Refactor into production model supporting:

  • email login;
  • password hash migration;
  • account lock;
  • session revocation after password change;
  • tenant-specific roles;
  • audit event.

Expected entities:

account
login_identifier
credential
auth_session
tenant_membership
tenant_role_assignment
authentication_event

Requirement:

Users can login via company OIDC.
Some users also have password fallback.
Email from IdP can change.

Design:

  • link key;
  • account matching policy;
  • audit event;
  • unlink policy;
  • fallback login risk.

Expected answer:

issuer + external sub -> external_identity_link -> account -> subject
email only as claim, not identity key

Exercise 3 — Incident Response

Scenario:

A user's password is leaked.
They have 4 sessions, 2 refresh tokens, 1 API key, and 1 passkey.

Design revocation:

  • mark password credential compromised;
  • increment credential version;
  • revoke sessions tied to old credential version;
  • revoke refresh token family;
  • decide whether API key is affected;
  • keep passkey unless evidence says compromised;
  • emit audit events.

27. Apa yang Harus Diingat

Authentication domain model adalah fondasi. Library hanya mengeksekusi model itu.

Yang harus tertanam:

Account != Person
Subject != Email
Credential != Session
Authenticator != Credential
Tenant hint != Tenant authorization
MFA != boolean
Recovery != reset_token column
Audit != console log
Token != identity lifecycle

Jika modelnya benar, Spring Security, Jakarta Security, Keycloak, OIDC, session cookie, JWT, passkey, dan API key bisa diintegrasikan secara konsisten.

Jika modelnya salah, framework terbaik pun hanya membungkus kekacauan.


28. Referensi

  • NIST SP 800-63-4 Digital Identity Guidelines — https://pages.nist.gov/800-63-4/
  • NIST SP 800-63B-4 Authentication and Authenticator Management — https://csrc.nist.gov/pubs/sp/800/63/b/4/final
  • OWASP Authentication Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
  • OWASP WSTG: Testing for Account Enumeration and Guessable User Account — https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/03-Identity_Management_Testing/04-Testing_for_Account_Enumeration_and_Guessable_User_Account
  • OWASP Top 10 2021 A07 Identification and Authentication Failures — https://owasp.org/Top10/2021/A07_2021-Identification_and_Authentication_Failures/
  • OpenID Connect Core 1.0 — https://openid.net/specs/openid-connect-core-1_0.html
  • WebAuthn Level 3 — https://www.w3.org/TR/webauthn-3/
Lesson Recap

You just completed lesson 08 in start here. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.