One of UniAuth's core privacy guarantees is that no OAuth client ever sees a user's real internal ID. Instead, each app receives a pairwise subject identifier — a deterministic, app-specific pseudonym computed with HMAC-SHA256. Two different apps that authenticate the same user will receive completely different sub values. This post explains the construction, its properties, and the operational considerations.
The Problem: Cross-Service Correlation
Most identity providers issue a single, global user identifier. If you sign in to App A and App B with the same Google account, both apps receive the same sub claim in your ID token. This means App A and App B can trivially correlate your activity by comparing user IDs — even if you never consented to the two apps sharing data about you.
In regulated environments (GDPR, CCPA) and privacy-sensitive deployments, this is a significant liability. UniAuth eliminates it at the protocol level by ensuring that user identifiers are scoped to each client application.
The HMAC-SHA256 Construction
The pairwise identifier is computed by a single function in lib/oauth.ts:
export function getPairwiseSub(userId: string, clientId: string): string {
return crypto
.createHmac("sha256", PAIRWISE_SECRET)
.update(`${userId}:${clientId}`)
.digest("hex")
}
The inputs are the user's real UUID and the OAuth client's client_id. The key is PAIRWISE_SECRET, a dedicated HMAC key configured via environment variable. The output is a 64-character hexadecimal string — the SHA-256 HMAC digest.
For the same user across two different apps:
Real UUID: 550e8400-e29b-41d4-a716-446655440000
App A (client_id: "app-a"):
HMAC-SHA256("550e8400...:app-a", secret)
= "2b7f16373f76cbdf9c0241ae59903ee2d143830d4edf74693b608fd2357cc219"
App B (client_id: "app-b"):
HMAC-SHA256("550e8400...:app-b", secret)
= "6a928c03d0e6cb00a35a31f769348d20a15e20eba64cdba9f088fac9917e18b7"
The two identifiers share no common structure. Without the HMAC key, there is no way to determine whether they belong to the same user.
Properties of the Construction
Determinism
The same (userId, clientId) pair always produces the same output. This means the app receives a stable identifier across logins — they can associate sessions, store preferences, and manage the user relationship without the identifier changing.
One-Wayness
HMAC-SHA256 is a pseudorandom function. Given the output and the clientId, an attacker cannot recover the userId without the secret key. Even the identity provider's operators cannot reverse the mapping in the database without access to the PAIRWISE_SECRET environment variable — the secret is never stored in the database.
Collision Resistance
SHA-256 produces 256-bit digests. The probability of two different (userId, clientId) pairs producing the same pairwise identifier is approximately 1 in 2^128 (by the birthday bound). For any practical number of users and clients, collisions will not occur.
Independence
Even if an attacker compromises one OAuth client and obtains all pairwise identifiers for that client, they learn nothing about the identifiers for any other client. Each client's identifiers are derived with a different input to the HMAC, producing statistically independent outputs.
Why PAIRWISE_SECRET Differs from JWT_SECRET
UniAuth uses two separate secrets: JWT_SECRET for signing session JWTs and access tokens, and PAIRWISE_SECRET for computing pairwise identifiers. They can be the same value (and PAIRWISE_SECRET falls back to JWT_SECRET if unset), but separating them provides an important operational benefit: you can rotate JWT_SECRET without changing pairwise identifiers.
JWT rotation happens when you suspect key compromise or as a periodic hygiene measure. If the same secret were used for pairwise identifiers, rotation would change every user's sub across every app — effectively making every user a new user in every connected application. Downstream apps would create duplicate accounts, lose user data, and generally break.
With separate secrets, JWT rotation invalidates sessions and tokens (users re-authenticate), but pairwise identifiers remain stable. Conversely, if you rotate PAIRWISE_SECRET, you intentionally break user linkability — which might be desirable in a privacy event (e.g., you want to sever the ability to correlate old and new identifiers).
# .env — recommended configuration
JWT_SECRET=your-jwt-signing-secret-here
PAIRWISE_SECRET=a-completely-different-secret-here
Key Rotation: What Happens
If you rotate PAIRWISE_SECRET, every pairwise identifier changes. This is a breaking change for all connected OAuth clients. The migration path depends on your deployment:
- Small deployment (handful of apps): Rotate the secret, then manually update user mappings in each downstream app using the admin API.
- Large deployment: Use a dual-secret transition. Deploy with both old and new secrets, compute identifiers with the new secret, and provide a one-time migration endpoint that maps old identifiers to new ones (requires the old secret). Remove the old secret after all clients have migrated.
We intentionally do not automate this migration because the pairwise guarantee means we do not have a mapping table. The mapping is computed on the fly, so there is nothing to "update" in UniAuth's database. The migration burden falls on the downstream apps, which is the correct architectural boundary — they own the user data associated with the old identifier.
Where Pairwise Identifiers Appear
The pairwise sub is used everywhere an OAuth client might see a user identifier:
- ID token
subclaim — the primary user identifier in OIDC. - Access token
subclaim — used by resource servers to identify the user. - UserInfo endpoint
subfield — returned by/api/oauth/userinfo. - SAML NameID — SAML service providers also receive pairwise identifiers via
getPairwiseSub().
Internally, UniAuth access tokens carry a private uid claim with the real user ID for database lookups. This claim is never exposed to OAuth clients — they only see the signed JWT's sub. The uid claim is used by the userinfo endpoint and token introspection to find the user row in the database, since we cannot look up a user by pairwise identifier without brute-forcing the HMAC across all users.
Comparison with OpenID Connect Pairwise Spec
The OpenID Connect Core specification (Section 8.1) defines pairwise subject identifiers and requires that each client receive a distinct sub value for the same user. It does not prescribe a specific algorithm, leaving the construction to the provider.
Our implementation is compliant with the spec and follows the recommended approach from the OIDC implementer's guide: an HMAC-based construction keyed with a provider secret. Some providers use a different approach — encrypting the user ID with a per-client key, or maintaining a lookup table of (user, client) to random identifier mappings. The HMAC approach has the advantage of being stateless (no lookup table to maintain or migrate) and deterministic (the same inputs always produce the same output, so we never need to store the mapping).
One area where we go beyond the spec is SAML. The OIDC pairwise requirement applies only to OIDC clients, but we extend the same construction to SAML service providers. This ensures consistent privacy properties regardless of the federation protocol.
Practical Considerations
If you are building an OAuth client that connects to UniAuth, the pairwise identifier is transparent. You receive a sub claim; you use it as the user's identity. The fact that it is computed via HMAC rather than being the user's real UUID does not change how you use it. The identifier is stable across logins, unique per user within your app, and suitable as a primary key or foreign key in your database.
The only scenario where pairwise identifiers require extra thought is when you operate multiple OAuth clients that need to share user identity — for example, a mobile app and a web app that are logically the same product. In this case, register them as the same OAuth client (with multiple redirect URIs) rather than as separate clients, and they will receive the same pairwise identifier.