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)
- Provider validates credentials → writes user record to storage
- Issues authorization code
- Client exchanges code → tokens issued
- If step 3 fails for any reason, user record already exists with no valid session
After (lazy mode)
- Provider validates credentials → passes
commit payload to success(), no write yet
- Issues authorization code (payload stored alongside code, TTL-scoped)
- Client exchanges code →
finalize() is called → user record written → tokens issued atomically
- 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
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:
Proposed solution
Introduce a
persistence.registrationoption on the issuer that controls when provider-side writes are committed:"immediate"— current behavior; commit duringsuccess(). Default."lazy"— defer commit until/tokenexchange succeeds. The provider passes acommitpayload throughsuccess(), stored alongside the authorization code and handed to the provider's newfinalize()hook at exchange time.If
finalize()throws, the issuer returnsserver_errorwithout consuming the code, so the client can retry safely with the same code.Before / after
Before (immediate — current default)
After (lazy mode)
commitpayload tosuccess(), no write yetfinalize()is called → user record written → tokens issued atomicallyReference implementation
A working implementation covering
issuer.ts,provider.ts,password.ts, tests, and documentation is available at:#328