Migrating to passkey-first identity in 0.6.0.0Released on 2026-07-16, 0.6.0.0 intentionally breaks the old aether-auth API and storage model. It is a replacement, not an in-place password-schema upgrade. There are no compatibility wrappers, legacy-session readers, password-verification bridges, group/permission translations or primary JWT fallback.

Migrating to passkey-first identity in 0.6.0.0

Released on 2026-07-16, 0.6.0.0 intentionally breaks the old aether-auth API and storage model. It is a replacement, not an in-place password-schema upgrade. There are no compatibility wrappers, legacy-session readers, password-verification bridges, group/permission translations or primary JWT fallback.

The generic auth/session tools in aether-core remain for applications that are not adopting Aether Identity. They cannot authenticate an Aether Identity route and must not populate its IdentityContext.

Removed API

Removed from aether-authReplacement or action
AbstractUser, UserModelImmutable User plus an opaque UserId; persistence is owned by IdentityStore
ORM Session, SessionsIdentitySession and atomic session commands; never query identity tables through aether-db
Auth, AuthMiddlewareIdentityService, IdentityMiddleware and passkey/recovery protocol services
AuthJwt, JwtService, JwtMiddleware, PureJwtNo identity replacement; use opaque session, device or service credentials
PasswordHasher and password fieldsNo replacement; every person enrolls a passkey or uses the explicit recovery path
Permission, Permissions, Group, GroupsOrganization OrgRole/OrganizationRole, Capability and application CapabilityResolver
SystemClock and kotlin.random.Random useInject IdentityRuntime clock and secure random capabilities

Do not recreate removed classes in application code. That would preserve the vulnerabilities and ambiguous authority the breaking release is designed to remove.

Dependencies and source sets

Keep the storage-neutral engine in common code and add only the adapters a deployment uses:

kotlin {
    jvm()
    wasmJs { browser() }
    wasmWasi { nodejs() }

    sourceSets {
        commonMain.dependencies {
            implementation("codes.yousef.aether:aether-auth:0.6.0.0")
        }
        jvmMain.dependencies {
            implementation("codes.yousef.aether:aether-auth-postgresql:0.6.0.0")
        }
    }
}

Add aether-auth-firestore, aether-auth-summon, aether-auth-oidc, aether-auth-saml or aether-auth-scim only where required. Enterprise and UI libraries must not be added to aether-auth's runtime classpath. wasmWasi does not use aether-auth-summon.

Compile all real targets during the migration. A JVM-only successful build does not prove that common identity code works on wasmJs or wasmWasi.

Data migration

There is no automatic migration for legacy users, password hashes, groups, permissions or sessions. Use this controlled process:

  1. Export only reviewed profile attributes needed by the application, such as display name, verified contact address, locale and application-owned profile keys. Do not import password hashes, session keys, JWTs, TOTP seeds, group joins or permission rows.
  2. Create a new UUID identity for each imported profile. Keep any old-to-new mapping in an access-controlled migration system, not in a public repository or browser response.
  3. Map application tenancy deliberately. Create organizations and memberships with the least privileged role. Do not translate isSuperuser, isStaff, a legacy group or a global permission into organization owner.
  4. Invite the user to enroll a discoverable, UV-required passkey. If invitation delivery is not possible, use the explicitly configured recovery/admin-enrollment path and its audit trail.
  5. Redeeming a new-user invitation atomically creates the identity and invited membership and issues a 15-minute invitation recovery-assurance session. That session can only register a passkey or log out. Successful registration revokes it and displays ten recovery codes once. Already authenticated identities may instead use the organization-scoped invitation acceptance route, which does not create another user.
  6. Issue a normal identity session only after a successful passkey ceremony. Old sessionid cookies and JWTs remain invalid.
  7. After the rollback window, delete password hashes and other retired authentication material according to the retention and incident policies.

A profile import is not authentication. Email equality does not prove account ownership and must never auto-link an OIDC or SAML subject.

Move identity routes to the fixed paths:

  • passkey, session, recovery and tenant resources under /identity/v1;
  • anonymous invite-only enrollment at /identity/v1/invitations/enroll;
  • device start and exchange at /oauth/device_authorization and /oauth/token;
  • SCIM at /scim/v2;
  • OIDC/SAML under /identity/v1/federation/{tenantId}/{providerId}.

Device clients must send the same client_id on authorization, device-code exchange, refresh, and revocation requests. client_name is display-only and is no longer accepted as a client identifier.

Replace sessionid with __Host-aether_session. The default cookie is Secure, HttpOnly, SameSite=Lax, Path=/, with no Domain. Its value is an opaque selector.secret; only a versioned HMAC digest is persisted. Do not put an identity token in local storage or return it in JSON.

Cookie-authenticated mutations require an exact allowed Origin and the session-bound X-CSRF-Token header. Delete code that reads CSRF values from forms or query parameters.

Authorization migration

Every tenant route includes an organization ID and resolves a membership for that same ID. Never copy an organization role into aether-core's global role set. Use the fixed roles only for identity management and attach application authority through a resolver:

val seenCapabilities = CapabilityResolver { context ->
    if (context.membership?.role == OrganizationRole.PUBLISHER) {
        setOf(Capability("package.publish"))
    } else {
        emptySet()
    }
}

Apply requireOrganization(expectedId) before requireCapability(...). Both a missing resource and a resource in another tenant return not_found. Platform-level account recovery is a separate authorizer; no organization role grants it.

Storage cutover

Identity persistence no longer uses ActiveRecord, Model, QueryAST or application transactions. Select one adapter and exercise the shared conformance suite before cutover.

For PostgreSQL, create a separate database, apply reviewed migrations V001 through V011 with the checksum runner, and use a short-lived deployment role to call the V009 provision_environment(environment, namespace) function. PUBLIC execution is revoked and the normal runtime/PostgREST role must not receive provisioning access. Runtime assertions are read-only and fail when the marker is absent or mismatched. Call only the versioned runtime RPC functions after provisioning.

V010 moves the reviewed WebAuthn completion implementations into the non-API aether_identity_internal schema, revokes all PUBLIC access there, and leaves four fixed, locked-search-path SECURITY DEFINER wrappers in aether_identity. Expose only aether_identity through PostgREST. The wrappers ensure a deterministic credential, session, recovery, or uniqueness rejection commits a failed ceremony and generic rejection audit while all partial completion writes roll back.

V011 adds the tenant/provider control row and logical federation-session epoch. The first exact provider acquire creates an enabled version-zero/epoch-zero control; an administrator may instead win the initial race by atomically creating the absent provider disabled at version zero/epoch one. Every later provider-control compare-and-set advances its version. Disabling additionally advances the session epoch; re-enabling retains that epoch. Existing session rows are retained and fail epoch validation, including after re-enable, without a provider-wide scan. Lease-backed challenge, link, replay, session-create, touch, and rotation commands recheck the exact control version in their transaction. Federation JIT linking inserts the new email-key-free user, viewer membership, replay receipt, external link, and audit as one unit; any uniqueness, replay, stale-lease, or disabled-provider failure leaves no orphan user or membership.

For Firestore, use a separate project/database, deploy the bundled indexes and field overrides, and explicitly provision marker schema version 2 at the database-global singleton aetherIdentityEnvironment/current. Namespace-local environment/current documents from a prior preview are ignored. Provisioning succeeds only for the exact existing environment/namespace and will not let two namespaces share one database. Use an authority-side OAuth credential source. A namespace alone does not make a shared development/production database safe.

Do not dual-write legacy password sessions and new identity sessions. If the application needs a staged rollout, run the old and new login surfaces as separate security domains, keep new identity routes inaccessible to old credentials, and migrate cohorts only after passkey enrollment.

Federation and provisioning

External links use (tenant, provider, issuer, subject). Do not link by email. Just-in-time provisioning is off by default and, when explicitly enabled, creates a new viewer rather than merging an existing user. The adapter commits that user, viewer membership, assertion receipt, external link, and audit atomically. Provider secrets stay in a secret manager.

SCIM deprovisioning disables the tenant membership and tenant sessions/tokens; it does not delete the stable user or affect another organization. Unknown groups grant no role. Federated sessions record their authentication method and still require passkey step-up for sensitive operations.

Migration verification

Before deleting the old implementation, verify:

./gradlew verifyExpectedSourceTasks :aether-auth:jvmTest :aether-auth:wasmJsNodeTest :aether-auth:wasmWasiNodeTest
./gradlew :aether-auth-postgresql:allTests
./gradlew :aether-auth-firestore:allTests
./aether-auth-firestore/run-emulator-gate.sh
./gradlew :aether-auth-oidc:allTests :aether-auth-saml:allTests :aether-auth-scim:allTests
./gradlew :aether-auth-summon:jvmTest :aether-auth-summon:wasmJsBrowserTest :example-app:wasmJsBrowserTest
./gradlew :aether-cli:test :example-app:allTests :aether-core:allTests
./gradlew check

cmake -S aether-identity-wasi-host -B build/identity-wasi-host -DCMAKE_BUILD_TYPE=Release
cmake --build build/identity-wasi-host --config Release --parallel 2
ctest --test-dir build/identity-wasi-host --output-on-failure

npm ci --prefix e2e-tests
npm test --prefix e2e-tests
npm run typecheck --prefix e2e-tests
npm exec --prefix e2e-tests -- playwright install chromium firefox webkit
npm run test:browser --prefix e2e-tests

The disposable state-changing browser journey and the unsupported production wasmWasi component-host integration have additional handling documented in Testing. For production deployments, follow the adversarial-review and manual hardware-passkey validation guidance in Identity deployment. Treat any remaining import of a removed aether-auth symbol, password field, global identity permission, or JWT fallback as a migration defect.

Architected in Kotlin. Rendered with Materia. Powered by Aether.
© 2026 Yousef.