Adding phone one-time-code login to a B2B SaaS product turns OAuth into an account-recovery problem, not a button-placement problem. Short answer: keep a provider's stable subject as the account anchor, make linking an explicit authenticated action, and test recovery as a state machine so a changed phone number or revoked grant cannot create a second identity.
I approach this like an on-call system. A login that looks fine in a happy-path demo can still strand a paying administrator at 02:00, or quietly create duplicate deliveries when two callbacks race. The useful unit is the account transition: what evidence arrived, what identity we already trust, and which recovery path is allowed next.
1. Using email or phone as the OAuth identity key
OAuth providers return a subject identifier (sub) scoped to an issuer. Email and phone are attributes. They can be reassigned, reformatted, or changed during recovery. If the database uses the phone entered for an OTP as the primary OAuth key, a recycled number can attach a new person to an old workspace.
The safer model has two records: a local account with its own immutable ID, and an external identity keyed by (issuer, sub). Store the current email and phone separately, with verification timestamps. A successful OAuth callback may discover an existing external identity; it must not silently merge on a matching email.
That extra prompt is deliberate friction. It gives support and audit logs a clear event: an already authenticated member requested a new sign-in method. OWASP's Authentication Cheat Sheet also treats recovery and reauthentication as security-sensitive flows, not as ordinary profile edits.
2. Treating a callback as proof of an account switch
The callback is a message in a protocol, not a command to replace the current session. Validate the authorization response, exchange the code on the server, verify issuer, audience, signature, nonce, and state, then decide which local account the (issuer, sub) record belongs to. Do not accept an account ID from a query parameter or from a client-side redirect.
No shortcut.
Here is the small boundary I want in a Go service. The repository lookup and session writer are intentionally generic; the important behavior is that an unknown identity enters a pending-link flow instead of an automatic merge.
package auth
import "errors"
type ExternalIdentity struct {
Issuer string
Subject string
AccountID string
}
var ErrLinkRequired = errors.New("explicit account link required")
func ResolveCallback(currentAccount string, id ExternalIdentity, known *ExternalIdentity) (string, error) {
if known == nil {
return "", ErrLinkRequired
}
if known.Issuer != id.Issuer || known.Subject != id.Subject {
return "", errors.New("identity record mismatch")
}
if currentAccount != "" && currentAccount != known.AccountID {
return "", errors.New("session account mismatch")
}
return known.AccountID, nil
}
The production version should also make the insert unique on issuer and subject, and record an idempotency key for the callback. I want a retry to return the same account, not to create another membership or send another welcome message. Short code. Serious boundary.
How should OAuth integration mistakes preserve identity continuity during recovery?
Start with a recovery state machine and write down the permitted transitions before adding UI. For a B2B SaaS account, a typical sequence is authenticated -> add_phone -> verify_otp -> recovery_ready; an OAuth sign-in for an unknown subject is authenticated -> link_review, never unknown -> existing_account.
The phone OTP proves control of a number at one moment. It does not prove ownership of a workspace, billing role, or an old OAuth subject. Require a second factor already bound to the account, an administrator approval, or a documented support procedure before changing the anchor. Rate-limit code attempts, expire codes, and avoid revealing whether a phone number is registered. These controls align with OWASP guidance on generic authentication responses, throttling, and recovery handling.
I test the transitions with failure injection: resend a code after it expires, submit an older code after a newer one, revoke the OAuth grant between authorization and callback, and run two callbacks concurrently. In one drill, I start with a tenant administrator whose phone is still verified, open two browser sessions, and let both sessions begin linking the same OAuth subject; one transaction should win the unique constraint while the other receives a deterministic conflict that the UI can explain. Then I expire the OTP, retry the callback with the same state value, and remove the provider grant before asking for a fresh login. The recovery log should show each rejected transition with its reason code and correlation ID, while the administrator remains attached to the original immutable account. I also run the test after a deploy, because a migration that changes collation or uniqueness semantics can reintroduce the exact duplicate-account path that the application code appears to prevent. The invariant is simple: one external subject maps to one local account, and every recovery operation is replay-safe. A pager should show a rejected transition with a reason code, not a mysterious second account.
3. Forgetting session and token boundaries
An OAuth access token is for an API resource; it is not your application's session cookie. Keeping provider tokens in the browser, or using an access token as a durable local credential, makes revocation and logout ambiguous. Mint a short-lived local session after the callback, rotate it after privilege changes, and keep refresh tokens server-side with encryption and a revocation record.
Phone recovery needs the same boundary. Verification should produce a narrowly scoped, one-time recovery grant that can add or replace a factor only after policy checks. It should not become a general bearer token. Log the account ID, factor ID, issuer, subject hash, and policy decision; never log OTP values or raw tokens.
4. Shipping without an account-recovery drill
The final mistake is procedural: deploying OAuth and OTP together, then discovering the recovery path during an incident. Build a table-top drill into the release checklist. Pick a test tenant, remove its phone factor, revoke its provider grant, and prove that an authorized administrator can restore access without changing the tenant's immutable account ID.
| Decision point | Prefer | Not suitable when |
|---|---|---|
| Identity anchor |
(issuer, sub) plus local account ID |
A provider cannot supply a stable subject; use a different standards-compliant identity source. |
| New phone factor | Explicit link after reauthentication | The user has no existing trusted factor; route to supervised recovery. |
| Session material | Short-lived local cookie; server-side refresh token | A client-only app cannot protect a refresh token; keep the flow public-client appropriate and reduce scope. |
| Callback retries | Unique constraint and idempotency key | The database cannot enforce uniqueness; add a transactional reservation before launch. |
Measure recovery completion, duplicate-link attempts, callback replay rejects, and time to restore an administrator. Alert on sudden changes, but page only on a violated invariant or sustained recovery failure. Your mileage may vary by tenant policy; I'm not sure a single recovery rule fits regulated and self-serve accounts, so the policy decision belongs in configuration and review rather than hidden in callback code.
The catch is that this design adds prompts, a review queue, and a little database work. It is not suitable when the product requires anonymous, instant account creation with no durable account owner; in that case, keep OAuth identities isolated until a later verified merge. Stick with a simpler email-only flow when phone recovery is optional and there is no administrative data at risk, but do not call that identity continuity.
Top comments (0)