DEV Community

SvenNilsson228
SvenNilsson228

Posted on

Subscriber Identity Design: Email Changes, Account Continuity, and Provider Migration

Short answer: Make an opaque subscriber ID the account key, treat email as a verified sign-in attribute, and migrate the two on separate tracks. That keeps a media subscription attached to the same person when an address changes, while giving a logistics publisher one stable key for GDPR deletion and session revocation.

An email address is useful for contact and recovery. It's a poor primary key. If articles, entitlements, invoices, saved delivery alerts, and sessions all point to the current email string, a routine address change becomes a distributed rename. Miss one table and the subscriber appears to lose history; copy instead of move and one person appears to own two accounts.

The safer flow is small: authenticate the current account, create a pending email claim, verify the new address, then atomically promote that claim and invalidate existing sessions. Notify the old address as a security signal. OWASP's Authentication Cheat Sheet supports requiring reauthentication for sensitive account changes and rotating or invalidating sessions after reauthentication.

Keep the ID boring.

How should subscriber identity design handle email changes without breaking account continuity?

Separate three concepts that managed authentication dashboards often present on one screen: the subscriber record, the login identifier, and the session. The subscriber record owns the immutable subscriber_id. A login-identifier table maps a normalized, verified email to that ID. A session table also points to the ID, never to the email. Content access and logistics-newsletter entitlements follow the same rule.

This makes an email change an update to one mapping rather than a rewrite of business data. It also makes the deletion path legible: resolve the authenticated principal to one subscriber ID, revoke every session for that ID, then delete or anonymize the records covered by the service's retention policy. The legal details depend on the service and jurisdiction; the identity model's job is to provide a complete, auditable scope rather than decide that policy.

Consider the failure chain when email is the key. A reader changes dispatch@old.example to editor@new.example after the subscription service has already copied the old value into its paywall, newsletter scheduler, delivery-alert store, and session index. The profile write succeeds, but a delayed newsletter update still targets the old row. The next login creates a fresh row for the new address because the authentication adapter cannot find the stale paywall record. Now the current session sees no paid entitlement, the old session remains active under a key that no longer appears in the profile, and an account-deletion worker starting from the new address misses the old delivery alerts. None of those components made an exotic mistake; they followed a mutable value that had been mistaken for identity. With subscriber_id as the join key, the same workflow changes one verified mapping, and every dependent read continues to resolve the original account.

There is a catch. An internal ID doesn't prove that two separately created accounts belong to one person. Automatic merging based on matching email text is unsafe because control can change and aliases can be confusing. If a migration uncovers duplicates, quarantine them for an explicit, authenticated merge process or keep them separate. I'm not sure a fully automatic rule can be justified without stronger account-specific evidence; an eval set built from known duplicate and non-duplicate cases is what would settle that decision.

The provider boundary should carry the opaque ID as the application subject. During migration, maintain a temporary mapping from the old provider subject to the application subscriber ID and from the new provider subject to that same ID. Don't make either provider's subject the key in article history or subscription tables. This extra mapping is operational work, but it prevents a second identity migration when the next provider changes.

Put the state transition in code before choosing a provider

A notebook sketch is useful here, but the production question is transactional: can two requests claim the same new email, can a verification token be replayed, and can old sessions survive the change? The compact SQLite example below models the invariant without depending on a vendor SDK. It uses an opaque subscriber ID, stores only a digest of each verification token, and performs promotion plus session revocation in one transaction. Authentication of the current session belongs before request_email_change; it is deliberately an input precondition, not a pretend password system hidden inside the example.

import hashlib
import secrets
import sqlite3
import uuid


def normalize_email(value: str) -> str:
    return value.strip().casefold()


def token_digest(token: str) -> str:
    return hashlib.sha256(token.encode("utf-8")).hexdigest()


def create_schema(db: sqlite3.Connection) -> None:
    db.executescript(
        """
        PRAGMA foreign_keys = ON;
        CREATE TABLE subscribers (
            subscriber_id TEXT PRIMARY KEY,
            created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
        CREATE TABLE email_identities (
            normalized_email TEXT PRIMARY KEY,
            subscriber_id TEXT NOT NULL REFERENCES subscribers(subscriber_id)
                ON DELETE CASCADE,
            verified_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
        CREATE TABLE pending_email_changes (
            token_hash TEXT PRIMARY KEY,
            subscriber_id TEXT NOT NULL REFERENCES subscribers(subscriber_id)
                ON DELETE CASCADE,
            normalized_email TEXT NOT NULL UNIQUE,
            created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
        CREATE TABLE sessions (
            session_id TEXT PRIMARY KEY,
            subscriber_id TEXT NOT NULL REFERENCES subscribers(subscriber_id)
                ON DELETE CASCADE
        );
        """
    )


def request_email_change(
    db: sqlite3.Connection, subscriber_id: str, new_email: str
) -> str:
    token = secrets.token_urlsafe(32)
    with db:
        db.execute(
            "INSERT INTO pending_email_changes "
            "(token_hash, subscriber_id, normalized_email) VALUES (?, ?, ?)",
            (token_digest(token), subscriber_id, normalize_email(new_email)),
        )
    return token


def confirm_email_change(db: sqlite3.Connection, token: str) -> None:
    with db:
        pending = db.execute(
            "SELECT subscriber_id, normalized_email "
            "FROM pending_email_changes WHERE token_hash = ?",
            (token_digest(token),),
        ).fetchone()
        if pending is None:
            raise ValueError("Invalid or already-used verification token")

        subscriber_id, new_email = pending
        db.execute(
            "DELETE FROM email_identities WHERE subscriber_id = ?",
            (subscriber_id,),
        )
        db.execute(
            "INSERT INTO email_identities (normalized_email, subscriber_id) "
            "VALUES (?, ?)",
            (new_email, subscriber_id),
        )
        db.execute(
            "DELETE FROM pending_email_changes WHERE subscriber_id = ?",
            (subscriber_id,),
        )
        db.execute(
            "DELETE FROM sessions WHERE subscriber_id = ?",
            (subscriber_id,),
        )


def delete_account(db: sqlite3.Connection, subscriber_id: str) -> None:
    with db:
        db.execute(
            "DELETE FROM sessions WHERE subscriber_id = ?",
            (subscriber_id,),
        )
        deleted = db.execute(
            "DELETE FROM subscribers WHERE subscriber_id = ?",
            (subscriber_id,),
        ).rowcount
        if deleted != 1:
            raise ValueError("Subscriber does not exist")


def demo() -> None:
    db = sqlite3.connect(":memory:")
    create_schema(db)
    subscriber_id = str(uuid.uuid4())
    with db:
        db.execute(
            "INSERT INTO subscribers (subscriber_id) VALUES (?)",
            (subscriber_id,),
        )
        db.execute(
            "INSERT INTO email_identities (normalized_email, subscriber_id) "
            "VALUES (?, ?)",
            ("reader@old.example", subscriber_id),
        )
        db.execute(
            "INSERT INTO sessions (session_id, subscriber_id) VALUES (?, ?)",
            (str(uuid.uuid4()), subscriber_id),
        )

    token = request_email_change(db, subscriber_id, "reader@new.example")
    confirm_email_change(db, token)
    assert db.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 0
    assert db.execute(
        "SELECT normalized_email FROM email_identities"
    ).fetchone()[0] == "reader@new.example"


if __name__ == "__main__":
    demo()
Enter fullscreen mode Exit fullscreen mode

The uniqueness constraint turns a collision into a rejected state transition instead of silent account linking. The transaction also means a verification token cannot be successfully consumed twice. Production code still needs an explicit token-expiry rule, rate limits, generic responses that do not reveal whether an address is registered, and delivery of notifications through an outbox or equivalent durable mechanism. Those concerns are outside this short storage example, but they belong in the acceptance criteria.

One detail deserves caution: email normalization is policy, not universal truth. casefold() is an application choice in this example, not a claim that every mail system treats every address identically. Preserve the original address for display and delivery if needed, document the comparison rule, and test internationalized addresses against the exact mail pipeline. Don't improvise a new normalization rule halfway through migration.

Test migration behavior, not dashboard settings

Provider selection should start with an executable contract. Feed the same cases through the old adapter, the new adapter, and the application-owned identity service: a verified change preserves the subscriber ID and entitlements; an unverified change does nothing; a claimed address cannot be claimed again; confirmation consumes its token; account deletion removes all sessions associated with the subscriber ID; and retrying a completed operation has an intentional result. This is eval-driven migration work. A green login screen proves very little.

Use synthetic accounts for the contract suite and assign correlation IDs to each transition. Log the subscriber ID, operation type, result, and adapter name, but avoid raw email addresses and verification tokens. Track counts of requested, verified, rejected, and expired changes. A sudden gap between requested and verified changes may indicate delivery friction or user confusion, while collision rejections need a protected support path rather than a dashboard merge button.

For a staged migration, dual-read can be reasonable, but dual-write deserves skepticism because partial success creates two authorities. A cleaner shape is one application-owned write path with adapters at the authentication edge. Shadow-read the new provider, compare resolved subscriber IDs, and block promotion if the mapping differs.

No guesswork.

Prompt and model cost are mostly irrelevant to this identity path, which is itself a useful design conclusion. Keep probabilistic matching out of authorization, deletion, and account continuity. A model may help classify support tickets, but it should not decide that two subscribers are the same person. The irreversible blast radius is too large, and the evaluation target is exact equality rather than plausible language.

The provider decision can then be scored on evidence: export completeness, stable subject mapping, session-revocation semantics, reauthentication support, audit-event availability, rate limits, and the ability to test the full change flow outside production. A managed provider may reduce authentication maintenance, while an application-owned mapping layer adds portability. Self-hosting can increase control but also transfers patching, key management, abuse defense, and on-call responsibility to the team. Stick with the managed provider when the migration cannot meet the same security and operational contract; migrate when portability and deletion scope justify owning those controls.

Operate email change and account deletion as one identity discipline

Before deployment, walk the data graph from subscriber_id, not from email: subscription entitlements, article history, newsletter preferences, delivery alerts, support records, active sessions, and pending changes. Every relationship should either cascade, be explicitly removed, or follow a documented retention decision. Then exercise the deletion job against a synthetic subscriber and verify absence through the same reads used by production.

Require recent authentication before accepting a new address, verify the new address before promotion, notify the current address, and revoke or rotate sessions after the sensitive change. OWASP describes these controls as defenses against session theft and account takeover during sensitive account changes. Recovery deserves the same review because a recovery path that trusts only the new address can bypass the stronger change flow.

Roll out in cohorts. Compare invariant failures and support volume, keep the old-to-application subject map until the rollback window closes, and rehearse rollback before moving the next cohort. The exact retention window is a policy choice, so your mileage may vary; security, legal, and operations owners need to approve it together.

The final check is plain prose, not a generic checklist: one immutable subscriber ID must survive every valid email change, no unverified address may become a login identifier, a consumed token must stay consumed, and deletion must revoke every session tied to the account. If a candidate architecture cannot demonstrate those properties under retry and concurrency, its convenient dashboard is beside the point.

References

Top comments (0)