DEV Community

HieronymusFox1257
HieronymusFox1257

Posted on

How to Keep Email Continuity When Changing an Address Without Creating Duplicate Accounts (Audit-Ready)

Short answer: keep email continuity by changing the address as a verified login attribute without creating a new account, then revoke sessions. An immutable account ID preserves a driver's history while giving an auditor a precise chain of evidence.

In a logistics system, a forgotten password is rarely an isolated form. Dispatchers, warehouse staff, and drivers may share a shift handoff, and an email address can change when a contractor joins a new carrier. The dangerous shortcut is to create a second account for the new address. It splits delivery records and makes an audit answer depend on which account someone searched.

I start with a decision table. It keeps the security-versus-friction argument visible before implementation.

Approach Pick this when Audit and security trade-off
Immutable ID plus verified email change The person and their logistics history must remain continuous Best traceability; asks for an extra confirmation step
Admin-assisted change The old mailbox is gone or a driver is locked out Strong evidence if the admin records identity checks; slower and more expensive
New account The person is legally a different operator Clean separation, but historical access must be deliberately transferred

How can logistics teams change an email address without creating a new account?

Model the relationship explicitly. users.id never changes. users.email is unique only among active identities, and email_change_requests holds a normalized candidate address, an expiry, and a one-time token hash. The password-reset flow looks up the immutable ID, never an email copied into a shipment record.

The state transition is small: requested -> verified -> applied, with expired and cancelled as terminal states. A successful application writes an audit event containing actor ID, target ID, reason, timestamp, request ID, and the old and new addresses in protected audit storage. Do not put reset tokens or full message bodies in routine logs.

The email itself is not proof of possession until the user completes the confirmation link. OWASP recommends generic authentication responses so an attacker cannot enumerate accounts; the forgot-password endpoint should return the same message and roughly the same timing whether the address exists.

Here is a compact TypeScript service boundary. The repository and mailer are generic on purpose; the important part is the ordering and the evidence returned to the caller.

type ChangeRequest = {
  id: string;
  userId: string;
  candidateEmail: string;
  tokenHash: string;
  expiresAt: Date;
  status: "requested" | "verified" | "applied" | "cancelled";
};

type AuditEvent = {
  type: "email_change_requested" | "email_change_applied";
  actorId: string;
  userId: string;
  requestId: string;
  occurredAt: string;
  oldEmail?: string;
  newEmail?: string;
};

export async function requestEmailChange(input: {
  userId: string;
  candidateEmail: string;
  requestId: string;
}): Promise<void> {
  const normalized = input.candidateEmail.trim().toLowerCase();
  const user = await users.findById(input.userId);
  if (!user) return;

  const token = crypto.randomBytes(32).toString("base64url");
  const request: ChangeRequest = {
    id: crypto.randomUUID(),
    userId: user.id,
    candidateEmail: normalized,
    tokenHash: await hashToken(token),
    expiresAt: new Date(Date.now() + 15 * 60 * 1000),
    status: "requested",
  };
  await emailChanges.insert(request);
  await audit.append({
    type: "email_change_requested",
    actorId: user.id,
    userId: user.id,
    requestId: input.requestId,
    occurredAt: new Date().toISOString(),
  });
  await mailer.sendChangeLink(normalized, token);
}

export async function applyEmailChange(token: string, requestId: string): Promise<void> {
  const request = await emailChanges.findByTokenHash(await hashToken(token));
  if (!request || request.status !== "requested" || request.expiresAt <= new Date()) {
    throw new Error("invalid or expired change link");
  }

  await db.transaction(async (tx) => {
    const user = await tx.users.lockById(request.userId);
    const oldEmail = user.email;
    await tx.users.updateEmail(user.id, request.candidateEmail);
    await tx.emailChanges.markApplied(request.id);
    await tx.sessions.revokeAll(user.id, "email_change");
    await tx.audit.append({
      type: "email_change_applied",
      actorId: user.id,
      userId: user.id,
      requestId,
      occurredAt: new Date().toISOString(),
      oldEmail,
      newEmail: request.candidateEmail,
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

The database lock and unique constraint matter. Two browser tabs can confirm the same link; only one transaction may move the request to applied. The second sees a terminal state and cannot silently overwrite an address.

What should the forgot-password, session, and audit signals prove?

A reset token should be random, single-use, short-lived, and stored as a hash. After the password changes, revoke active sessions and refresh tokens. After an email change, do the same, then require a fresh login at the new address. This is a deliberate friction point: a stolen browser session should not become a permanent identity transfer.

For observability, emit counters for reset_requested, reset_completed, email_change_requested, email_change_applied, token_expired, and session_revoked. Add latency histograms for the mail send and confirmation endpoints. Alert on a sudden rise in requests per account, repeated expirations, or changes followed by an immediate password reset from a new device. Metrics show shape; the audit event explains one shipment manager's case.

I first assumed a unique email constraint was enough. It wasn't. In a test dataset, a retry produced two email_change_applied rows for request chg_1842; the UI looked successful, but the audit export had contradictory timestamps, the dispatcher's old session remained active on a shared tablet, and a nightly reconciliation could not tell which address had been confirmed first. We traced the sequence through the correlation ID, found that the worker had replayed after a network timeout, and added an idempotency key derived from the request ID. The transaction state check above now makes the retry boring, while the session-revocation event gives the auditor a single, ordered explanation. Boring is good.

Ship the audit trail.

Keep logs useful and restrained. Record correlation ID, actor ID, request ID, outcome, and reason category. Redact tokens, password-reset URLs, and unnecessary address copies from application logs. Your mileage may vary on retention periods; legal, privacy, and carrier contracts decide that number, while the schema should make deletion and export possible.

Choosing the right boundary for recovery operations

Use the immutable-ID pattern when one person should retain route history, proof-of-delivery access, and role assignments. Use an admin-assisted path when the old mailbox is unavailable, but require an independent identity check and a second approver for high-risk roles such as payout or fleet administration. Create a new account only when the legal subject is different, then record an explicit transfer rather than merging rows by hand.

The catch is that continuity can be the wrong goal. If a carrier contract ends and a replacement worker receives the address, preserving the old identity would grant access to historical personal data. In that case, keep the old account, disable it, and provision a new identity with a documented data-access boundary. A help-desk-only workflow is also unsuitable for a 24-hour operation unless staffing and escalation targets are part of the design.

This approach does not remove every attack. Mailbox takeover, SIM swapping, and a compromised administrator still require separate controls such as phishing-resistant MFA, least privilege, and periodic access review. I am not sure a single risk score can capture every carrier's threat model; a tabletop exercise with real audit questions will expose gaps faster than another dashboard.

References

Top comments (0)