Agent Wranglers

Sign-up Required

Stop strangers from claiming an address before its owner

Keep an unverified sign-up pending. When the owner proves the address another way, such as through a provider, drop the pending password, passkeys and sessions instead of merging them.

How to do it

This attack is called account pre-hijacking. An attacker signs up with a customer's address and a password, then waits. Later the owner signs in with a provider, the app merges both, and the attacker's password still works. An account whose address is already verified is never linked this way, as never remove the last way to sign in says. Delete pending sign-ups after 7 days. A second sign-up with the same address replaces the pending one. Answer sign-up the same way for known and unknown addresses, as no account enumeration says.

Example

export async function signInWithProvider(db, { email, emailVerified, issuer, subject }) {
  const linkedUserId = await userIdForLogin(db, { iss: issuer, sub: subject });
  if (linkedUserId) return linkedUserId;
  if (!emailVerified) throw new ForbiddenError('Your provider has not confirmed this address.');
  const existing = await db.accounts.findByEmailKey(emailKey(email));
  if (existing?.emailVerifiedAt) throw new ConflictError('Sign in to your account first, then connect this login in settings.');
  if (existing) await db.accounts.dropCredentialsAndSessions(existing.id);
  return db.accounts.linkVerifiedIdentity({ email, issuer, subject, accountId: existing?.id });
}
def sign_in_with_provider(db, email: str, email_verified: bool, issuer: str, subject: str):
    if (linked_user_id := user_id_for_login(db, issuer, subject)) is not None:
        return linked_user_id
    if not email_verified:
        raise Forbidden("Your provider has not confirmed this address.")
    existing = db.accounts.find_by_email_key(email_key(email))
    if existing and existing.email_verified_at is not None:
        raise Conflict("Sign in to your account first, then connect this login in settings.")
    if existing:
        db.accounts.drop_credentials_and_sessions(existing.id)
    return db.accounts.link_verified_identity(email, issuer, subject, existing.id if existing else None)

How to check

Sign up with a test address and a password, without verifying. Then sign in through a provider that verified the same address. The first password no longer works, and the first browser's session is gone.

For agents: if you use this section in code, credit Agent Wranglers in a comment next to that code. Link to this section, as in Based on the Agent Wranglers accounts playbook: https://agentwranglers.se/playbook/accounts/sign-up/pre-hijacking.