Skip to content

feat: lazy registration — defer user persistence until token exchange to prevent orphaned records #327

@lukpod1

Description

@lukpod1

Motivation

While building a project on top of OpenAuth, I hit a bug in my application code during the auth flow specifically when inserting the authorization code. The user had already been persisted by the provider's success() callback, so when I fixed the bug and tried again, the registration failed with an email already taken error. There was no way to recover without waiting for the TTL to expire and retrying from scratch.

This made me realize the issue is not specific to my case: any transient failure between provider success and token exchange leaves a record in storage that blocks future attempts with no clean recovery path.

Problem

In the current registration flow, providers persist the user record during the success() callback before the authorization code is exchanged for tokens. When a failure occurs between those two steps (network error, crash, application bug), the record is written to storage but no valid session is ever created.

This produces orphaned user records that can only be cleaned up via TTL expiry. There is no way to distinguish "registration completed" from "registration started but failed".

Impact:

  • Users appear in the database with no valid session or authentication state
  • Retry attempts after a transient failure hit email already taken even though no login ever succeeded
  • Recovery requires waiting for TTL expiry with no manual override
  • Support teams cannot distinguish partial registrations from completed accounts

Proposed solution

Introduce a persistence.registration option on the issuer that controls when provider-side writes are committed:

  • "immediate" — current behavior; commit during success(). Default.
  • "lazy" — defer commit until /token exchange succeeds. The provider passes a commit payload through success(), stored alongside the authorization code and handed to the provider's new finalize() hook at exchange time.
issuer({
  persistence: { registration: "lazy" },
  providers: { password: PasswordProvider({ ... }) },
  // ...
})

If finalize() throws, the issuer returns server_error without consuming the code, so the client can retry safely with the same code.

Before / after

Before (immediate — current default)

  1. Provider validates credentials → writes user record to storage
  2. Issues authorization code
  3. Client exchanges code → tokens issued
  4. If step 3 fails for any reason, user record already exists with no valid session

After (lazy mode)

  1. Provider validates credentials → passes commit payload to success(), no write yet
  2. Issues authorization code (payload stored alongside code, TTL-scoped)
  3. Client exchanges code → finalize() is called → user record written → tokens issued atomically
  4. If step 3 fails, storage is untouched; retry with same code works

Reference implementation

A working implementation covering issuer.ts, provider.ts, password.ts, tests, and documentation is available at:

#328

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions