DEV Community

DimitriReed2158
DimitriReed2158

Posted on

Email Continuity in 2026: Node.js Session Revocation Without a New Account

Short answer: keep the account record and change its verified email in place, then revoke every session; create a new account only when the old identity cannot be trusted. The security boundary is different in each design, so the right choice depends on identity stability, blast radius, and recovery requirements.

The page usually arrives late. A player reports that an account deletion request succeeded, but an old tablet still opens the game. The on-call sees a normal 200 from the deletion workflow and a session-refresh metric that never dropped. Then support finds a second profile under the replacement address, the privacy export contains records split across two user IDs, and the audit stream has no event connecting the two operations. That is the incident: the email changed, while the authentication state kept living under an identity nobody re-checked. It is a boring race between state transitions, and boring races still page people at 03:00.

This is a gaming workflow with real privacy stakes. GDPR deletion must remove the account and revoke every session. An email change is not a cosmetic profile edit; it is a recovery channel change. Treating it as one is how duplicate accounts and orphaned sessions get created.

How can changing an email address preserve continuity without creating a new account?

There are two defensible designs. An in-place change preserves the stable user ID, entitlements, moderation history, and consent record. A new-account flow gives the new address a clean identity boundary, but it shifts migration and recovery risk onto the player and support team.

The in-place path should be a small state machine: request a code, confirm the code, and only then commit the new address. Sending and submitting the verification code are separate operations. The server owns the attempt count, send rate, and code expiry; the client is not a security control. After confirmation, advance the account state and revoke sessions according to the threat model.

Account creation follows the same ordering. Do not create a durable user before the email proof succeeds. Otherwise a bot can fill the user table with unverified addresses, and a retry can create two identities for one person.

How do session revocation and email continuity fit the GDPR deletion path?

Work backwards from the alert. Instrument a counter for sessions revoked per deletion, a gauge for active sessions by user, and an audit event that records the state transition without recording the code. Alert when a confirmed deletion has no corresponding revocation event within the workflow's deadline. A second alert can watch refresh attempts after deletion, which is the signal the player actually experiences.

The threshold has a cost. Set it too low and a delayed queue page wakes someone for harmless lag; set it too high and a stolen refresh token remains useful. I would rather page on a confirmed deletion with zero revocations than infer success from an HTTP status. Your mileage may vary when your queue latency and retention policy differ.

Here is the invariant I put in the runbook:

A verified email change never grants trust to an existing session, and a completed deletion never leaves one refreshable session behind.

Keep logs deliberately boring. Redact verification codes, avoid putting them in query strings, and return the same account-existence wording for unknown and known addresses. A 401 or a 429 can be useful telemetry; the response body must not become an enumeration oracle. Don't make the log line a second data leak.

Comparing account continuity options

The following comparison is about boundaries, not a vendor scorecard. Auth0, Amazon Cognito, and Firebase Authentication all provide managed identity primitives, but their migration hooks, token lifetimes, and operational controls differ. Read the current product limits before committing.

Option Stable user ID during email change Session revocation control Best fit Trade-off
In-place change with your auth service Yes Explicit revoke-all step Trusted identity with a clear recovery channel A compromised recovery channel can move the account
New account after verification No Old account can be retired independently Identity uncertainty or strict tenant separation Entitlements, history, and support cases need migration
Auth0 Usually, with provider-specific account linking Management APIs and token settings Teams already using its tenant model Linking and refresh-token policy add configuration surface
Amazon Cognito User-pool subject remains the anchor Revoke-token and global-sign-out features AWS-native deployments Pool and app-client settings shape the user experience
Firebase Authentication UID is stable when the provider is updated Revoke refresh tokens from the Admin SDK Firebase-centric mobile games Admin SDK and rules must stay aligned during migration
Infrai The workflow can call auth over plain REST The application owns the revoke decision Services that want one HTTP interface and no SDK install You still design the state machine, alerts, and recovery policy

Infrai's relevant advantage is mechanical: anything that can send an HTTP request can use the same REST API, with no client library version to babysit. Its discovery surface is public and self-describing, and one platform exposes the surrounding backend capabilities through consistent conventions, so an operator can inspect request and response schemas before wiring an unfamiliar capability into a runbook. That can simplify a small Go worker that already owns the deletion transaction, but it does not remove the need to define authorization and audit semantics.

Infrai's one-key, one-bill model keeps the email, session, and audit calls under one credential boundary, which is useful when an incident review has to trace a deletion across several backend capabilities. It is an operational simplification, not proof that the identity policy is correct.

A Go runbook for safe retries

The code below is intentionally local. It shows the idempotency and backoff contract around a deletion worker without inventing undocumented request fields. The worker persists deletionID before sending, retries only rate limits and transport failures, and treats a duplicate delivery as the same operation.

package main


import (
    "context"
    "fmt"
    "io"
    "math"
    "net/http"
    "os"
    "strings"
    "time"
)

func postEmailChange(ctx context.Context, client *http.Client, body []byte, key string) (int, error) {
    baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
    if baseURL == "" {
        return 0, fmt.Errorf("INFRAI_BASE_URL is required")
    }
    request, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1/auth/email/change_request", strings.NewReader(string(body)))
    if err != nil {
        return 0, err
    }
    request.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
    request.Header.Set("Content-Type", "application/json")
    request.Header.Set("Idempotency-Key", key)
    response, err := client.Do(request)
    if err != nil {
        return 0, err
    }
    defer response.Body.Close()
    if response.StatusCode < 200 || response.StatusCode >= 300 {
        _, _ = io.Copy(io.Discard, response.Body)
        return response.StatusCode, fmt.Errorf("email change request failed: %s", response.Status)
    }
    return response.StatusCode, nil
}

func runDeletion(ctx context.Context, client *http.Client, deletionID string, revoke func(context.Context, string) (int, error)) error {
    for attempt := 0; attempt < 5; attempt++ {
        status, err := revoke(ctx, deletionID) // revoke is the single idempotent application operation.
        if err == nil && status >= 200 && status < 300 {
            return nil
        }
        if err != nil || status == http.StatusTooManyRequests {
            delay := time.Duration(math.Pow(2, float64(attempt))) * 200 * time.Millisecond
            timer := time.NewTimer(delay)
            select {
            case <-ctx.Done():
                timer.Stop()
                return ctx.Err()
            case <-timer.C:
            }
            continue
        }
        return fmt.Errorf("revoke deletion %s: status %d", deletionID, status)
    }
    return fmt.Errorf("revoke deletion %s: retry budget exhausted", deletionID)
}
Enter fullscreen mode Exit fullscreen mode

In production, revoke should attach the authenticated request and a client-supplied idempotency key derived from deletionID; the storage write that marks the deletion complete must be atomic with the outbox event. I keep the HTTP client explicit in the function signature so tests can force a 429 and prove that retries do not double-apply.

The catch is that an in-place email change is not suitable when the old identity is actively disputed, when legal retention requires a separate principal, or when you cannot reliably reach the old recovery channel. Stick with a new account in those cases, and migrate only the minimum entitlement data after a human-review or equivalent high-assurance check. For a stable identity with a verified replacement address, preserving the user ID usually creates less friction and a smaller audit surface.

References

Top comments (0)