Logo

Asymmetric Encryption for Player PII in Multiplayer Games

How HPKE encryption protects user email addresses and personal data so the server can't read them even if compromised.

Rob Helmer

Rob Helmer

3/24/2026 · 9 min read

Tags:


Architecture diagram showing OAuth provider, auth worker encrypting PII with public key, ciphertext-only database, and separate email worker with private key.

Traditional authentication systems store user data (email, display name, preferences) in plaintext or with symmetric encryption. This creates a fundamental problem: if the server is compromised, all PII is exposed.

Instead, I’m using asymmetric encryption with a site-wide keypair. The backend workers encrypt PII immediately upon OAuth callback—and the auth database only stores ciphertext. The private key never lives on the auth servers at all.

This is the same approach I use at Stellar Whiskers—my casual gaming platform. I’ve tried to build privacy into the platform from the start.

The Overall Architecture

Before diving into cryptographic primitives, let me explain where plaintext PII is actually needed and how it fits into the overall security architecture.

graph TD
    subgraph "OAuth Provider"
        A[Google/Apple/etc.] -->|Callback with PII| B
    end

    subgraph "Auth Worker"
        B[OAuth Callback Handler] -->|Encrypt with Public Key| C
        C[PII Ciphertext] --> D[(Auth Database)]
    end

    subgraph "Email Worker"
        E[Background Worker] -->|Has Private Key| F
        F[Decrypt for Transactional Email]
    end

    D -.->|Ciphertext Only| E

    style D fill:#1e40af,stroke:#1e293b,color:#fff
    style E fill:#166534,stroke:#1e293b,color:#fff

Where plaintext PII is needed:

  1. OAuth callback: The OAuth provider (Google, Apple, etc.) sends plaintext email and name
  2. Email sending: When sending transactional or opt-in marketing emails, we need the plaintext email address
  3. Account management: User profile pages display their name and email

The key insight: The auth worker receives plaintext PII at OAuth callback, but immediately encrypts it before storing. The auth worker is deployed without the private key, so it cannot decrypt. Only the separate email worker holds the private key.

This means:

  • Auth database compromise: Attacker gets only ciphertext
  • Auth worker compromise: Attacker still can’t decrypt (no private key deployed)
  • Full platform compromise: Attacker needs both auth worker + email worker + database

Threat Modeling

Building a privacy-first system requires thinking through attack vectors upfront. This informs the choice of cryptographic primitive and key management strategy.

Threat Model

Attacker CapabilityWhat They GetWhat They Can’t Do
Auth database onlyCiphertextDecrypt PII
Auth worker + databaseCiphertextDecrypt PII (no private key)
Email worker onlyPrivate keyDecrypt without database access
Full compromiseEverything

Design goal: Make “database breach” a “recovery nuisance” rather than a “total identity leak.”

Key Management

The private key is:

  • Generated once at deployment
  • Stored as a secret in Cloudflare Workers (encrypted at rest)
  • Only deployed to the email worker
  • Never logged or exposed to auth workers

The public key is:

  • Deployed to all auth workers
  • Useless for decryption (asymmetric property)
  • Can be rotated independently

Substitution Attacks

Attack: An attacker who has compromised the database swaps ciphertext between users. For example, they replace your encrypted email with theirs, then use “forgot password” to reset your password to their email.

Mitigation: Bind ciphertext to user IDs at the database layer with a foreign key constraint. The encrypted PII is stored in the same row as the user ID, so swapping requires updating multiple fields atomically. Additionally, application-level validation checks that decrypted PII matches expected patterns.

This is a defense-in-depth measure: if an attacker can modify the database, they’ve already won. But binding ciphertext to user IDs raises the bar and prevents simple copy-paste attacks.

Deduplication vs. Anonymity

Problem: How do you detect duplicate signups without compromising privacy?

The user enters their email to sign up, and you need to check for duplicates. But the database is encrypted, so you can’t check directly. If you hash with a known salt, an attacker who compromises the database can run a dictionary attack: hash common email addresses and compare against the stored hashes.

Initial approach: I was considering Oblivious Pseudorandom Functions (OPRF)—a protocol where the Auth Worker computes a hash without learning the email or the salt. But this is overkill.

Better approach: A simple Hash Server. The Auth Worker sends the email in plaintext to an internal worker that holds the secret salt and returns the PRF output:

Auth Worker → Hash Worker: Email address (plaintext)
Hash Worker → Auth Worker: PRF(Secret, Email Address)

The Hash Worker is a trusted part of the infrastructure—the email address itself isn’t sensitive at this point (the user just entered it). The goal is just to keep the secret salt private from the Auth Worker, so database compromise doesn’t enable dictionary attacks.

This is much more practical than OPRF for my use case. The OPRF objective is to conceal the email from the Hash Server, but why do that when it’s already trusted?

The Encryption Primitive: HPKE

I’m using HPKE (Hybrid Public Key Encryption), a standard primitive that generalizes across ECIES and provides an easy path to post-quantum security.

Why HPKE and not RSA-OAEP? RSA-OAEP is a rarely used primitive with sharp edges. HPKE is a composition of well-known primitives (ECDH + KDF + AEAD) with a clean API. It’s the standard choice for modern asymmetric encryption.

Why not ECIES directly? ECIES is a family of schemes, not a single standard. HPKE standardizes the composition and provides a single, well-specified API.

Post-quantum readiness: HPKE supports X25519, P-256, and post-quantum key exchange algorithms (like Kyber) via the same interface. When PQC is required, I can swap the KEM without changing the application logic.

Implementation

// Site-wide keypair (generated once, deployed separately)
// Public key → Auth workers
// Private key → Email worker only (never deployed with auth)

// Auth worker: encrypt PII on OAuth callback
async function encryptPII(data: string, publicKey: CryptoKey): Promise<string> {
    const encoder = new TextEncoder();
    const ciphertext = await crypto.subtle.encrypt(
        {
            name: 'HPKE',
            kem: 'X25519',
            kdf: 'HKDF-SHA256',
            ae: 'AES-GCM'
        },
        publicKey,
        encoder.encode(data)
    );
    return btoa(String.fromCharCode(...new Uint8Array(ciphertext)));
}

// OAuth callback handler
async function handleOAuthCallback(profile: OAuthProfile) {
    const encryptedEmail = await encryptPII(profile.email, SITE_PUBLIC_KEY);
    const encryptedName = await encryptPII(profile.name, SITE_PUBLIC_KEY);

    // Store only ciphertext - auth worker cannot decrypt
    await db.users.insert({
        oauth_id: profile.id,
        email: encryptedEmail,
        name: encryptedName,
    });
}

// Email worker: decrypt only when needed for opt-in emails
async function sendTransactionalEmail(userId: string, template: EmailTemplate) {
    const user = await db.users.get(userId);
    const email = await decryptPII(user.email, SITE_PRIVATE_KEY);

    // Only decrypt for strictly opt-in transactional emails
    await mailer.send({ to: email, ...template });
}

Note: As of this writing, HPKE is not yet available in the Web Crypto API. I’m using a polyfill implementation. When native support lands, I’ll migrate.

Security Properties

This design provides several guarantees:

  1. Auth compromise ≠ PII breach: Even with full database + auth worker access, attackers only get ciphertext
  2. Private key isolation: The email worker is deployed separately with its own credentials and network policies
  3. Minimal decryption surface: PII is only decrypted by the background worker for transactional or strictly opt-in marketing emails
  4. Game state is non-PII: Board positions and card locations are stored separately as raw bitboards—no encryption needed since moves are public game state

The trade-off is operational complexity—you need separate deployment pipelines and credential management for the email worker. But it means a compromise of the website and auth worker or the auth database is not enough on its own to expose user email addresses or real names.

By isolating the Private Key, we transform a “database breach” from a “total identity leak” into a “recovery nuisance.” Attackers might see that a user exists, but they can’t see who they are.

Wait, What About Your Blog Newsletter?

Putting It All Together

The complete privacy-first architecture combines all three systems from this series:

  1. Game state → Efficient encoding (bitboards/card bitsets) → Fast sync, low bandwidth
  2. User data → Asymmetric encryption (HPKE) → Server can’t read PII
  3. Sessions → Secure cookies (HttpOnly + CHIPS) → No XSS theft, no tracking
graph TB
    subgraph "Client"
        GC[Game Client] -->|Encoded State| WS[WebSocket]
        BR[Browser] -->|OAuth Login| OAuthFlow
        BR -->|LocalStorage Save| LS[LocalStorage]
    end

    subgraph "Edge (Cloudflare Workers)"
        WS --> GSS[Game State Sync]
        OAuthFlow --> AW[Auth Worker]
        AW -->|Encrypt with Public Key| EncryptedPII
        LS -->|Debounced Sync| CS[Cloud Save Worker]
    end

    subgraph "Origin Server"
        EncryptedPII --> ADB[(Auth DB<br/>Ciphertext Only)]
        GSS --> GDB[(Game State DB<br/>Bitboard Deltas)]
        CS --> GDB

        subgraph "Separate Deployment"
            EW[Email Worker<br/>Has Private Key]
        end

        ADB -.->|Fetch for Email| EW
    end

    style ADB fill:#6b21a8,stroke:#1e293b,color:#fff
    style GDB fill:#1e40af,stroke:#1e293b,color:#fff
    style EW fill:#166534,stroke:#1e293b,color:#fff

Key properties:

  • Game state is efficiently encoded (4-300 bytes depending on game) - non-PII, stored as raw bitboards or card locations
  • PII is encrypted with site’s public key on OAuth callback (auth worker can’t decrypt)
  • Private key lives only on separate email worker (for transactional/opt-in emails)
  • Auth cookies are HttpOnly and partitioned (no XSS, no tracking)
  • No third-party auth providers (full control over privacy)
  • LocalStorage provides instant persistence with debounced cloud sync

Why This Matters for Privacy-First Games

The gaming industry has a tracking problem. Analytics providers, ad networks, and even auth providers build profiles that follow users across sites. For a privacy-focused platform like Stellar Whiskers, this is unacceptable.

This architecture ensures:

  • No cross-site tracking: Partitioned cookies prevent fingerprinting
  • Minimal data exposure: Server can’t read PII even if compromised
  • XSS resistance: HttpOnly cookies can’t be stolen by malicious scripts
  • Performance: Bitboard and location encoding reduces bandwidth by 3-10x
  • Privacy by default: No third-party dependencies that enable tracking

What’s Next

The multiplayer WebSocket system is currently in closed testing. Some things I’m working on next:

  1. Lightweight formal verification for game state transitions
  2. Continuous Agency for exploratory AI playtesting
  3. Encrypted email worker deployment for the newsletter system
  4. Native HPKE support in Web Crypto API (currently using polyfill)
  5. Hash Worker implementation for privacy-preserving deduplication (simple PRF with secret salt, not OPRF)

I’ll be writing about these as they’re implemented. If you’re building privacy-first games or curious about any of these techniques, let me know—I’m always happy to dive deeper.

Acknowledgments: Thanks to Eric Rescorla for his thorough technical review of this series. His feedback on the cookie security model, cryptographic primitive choices, and threat modeling significantly improved these posts.

Catch up on the series: