> Source: https://upvera.tech/guides/pairwise-identifiers/
> A pairwise identifier is a sub value that an OpenID Connect provider computes separately for each sector, usually each relying party's domain, so the same person gets a stable but different ID at every site. It is defined in OpenID Connect Core 1.0, section 8, and it stops two sites from linking their records on the ID alone. A provider lists it in subject_types_supported, and a client can ask for it with subject_type=pairwise when it registers.

[Guides](/guides/)Explainers

Explainer · · For developers · Upvera ID

# What is a pairwise identifier in OpenID Connect?

Short answer

A pairwise identifier is a sub value that an OpenID Connect provider computes separately for each sector, usually each relying party's domain, so the same person gets a stable but different ID at every site. It is defined in OpenID Connect Core 1.0, section 8, and it stops two sites from linking their records on the ID alone. A provider lists it in subject\_types\_supported, and a client can ask for it with subject\_type=pairwise when it registers.

## Two kinds of subject

Every OpenID Connect ID token carries a `sub` claim: the provider's identifier for the person. Section 8 of OpenID Connect Core 1.0 defines two types.

-   **public**: every client receives the same `sub` for the same person. It is the default when a provider's discovery document has no `subject_types_supported` entry.
-   **pairwise**: each client receives a different `sub`, so clients cannot correlate a person's activity across sites without that person's permission.

Pairwise does not strictly mean "one value per client". It means one value per **sector identifier**.

## What a sector identifier is

By default, the sector identifier is the host of the client's registered `redirect_uri`. A client that redirects to `https://app.example.com/auth/callback` has the sector `app.example.com`.

If a client registers redirect URIs on more than one host, it must also register a `sector_identifier_uri`. That is an https URL pointing to a JSON array of redirect URIs, and its host becomes the sector:

```json
[
  "https://app.example.com/auth/callback",
  "https://www.example.com/auth/callback"
]
```

The provider checks at registration that every registered redirect URI appears in the array. Clients with the same sector receive the same `sub` for a person. That is how a group of sites under one owner can recognize the same user, and how a client can later move its redirect domain without every user turning into a stranger.

## How providers derive it

Section 8.1 sets three requirements and leaves the method open:

1.  Only the provider can reverse the value.
2.  Different sector identifiers give different values.
3.  The calculation is deterministic.

The spec gives three examples: hash the sector identifier, the local account ID and a secret salt together; encrypt the same three values; or create a random identifier for each pair of sector and account and store it. A keyed hash is a common choice:

```python
import base64, hashlib, hmac

def pairwise_sub(key: bytes, sector: str, account_id: str) -> str:
    # length-prefix each part so "ab" + "c" never equals "a" + "bc"
    msg = b"".join(len(p).to_bytes(4, "big") + p
                   for p in (sector.encode(), account_id.encode()))
    digest = hmac.new(key, msg, hashlib.sha256).digest()
    return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
```

The derived methods need no extra table, but the key cannot change without giving every user a new `sub`. A stored random value per pair can survive key changes and migrations, at the cost of a lookup.

## Public and pairwise compared

|  | Public | Pairwise |
| --- | --- | --- |
| Same person at two different sites | Same `sub` | Different `sub` |
| Sites can join records on `sub` | Yes | No |
| Value depends on | The account | The account and the sector |
| Default when discovery lists no type | Yes | No |
| Your web and mobile clients see | The same value | The same value only if they use the same sector |

## Handling it as a relying party

1.  Read `subject_types_supported` from the provider's discovery document. If it lists only `pairwise`, that is what you will get.
2.  With dynamic registration, send `subject_type` and, if you use more than one redirect host, a `sector_identifier_uri`.
3.  Key accounts on the pair of `iss` and `sub`. Section 5.7 calls this the only guaranteed unique identifier for a person.
4.  Store `sub` as a case-sensitive string of up to 255 ASCII characters.
5.  If you call the UserInfo endpoint, check that its `sub` exactly matches the one in the ID token.

## Common mistakes

-   **Keying accounts on email.** The spec says email must not be used as a unique identifier. It can change and can even be reassigned.
-   **Case-insensitive storage.** A lowercased `sub` or a case-insensitive column collation can merge two different people.
-   **Moving the redirect domain.** Under the default rule, a new callback host is a new sector, and every returning user arrives with a new `sub`. Choose the sector before launch, or register a `sector_identifier_uri` from the start.
-   **Expecting one ID across products.** Products on different domains see different values unless they register the same sector.
-   **Treating pairwise as anonymity.** It only covers `sub`. If several sites receive the same email address, they can still link the person on it.

## Questions

### Can a site turn a pairwise sub back into the person's account?

No. Section 8.1 requires that nobody except the provider can reverse it.

### Does the sub change when the person changes their email?

No. A `sub` is locally unique within the provider and never reassigned, and it does not depend on email.

### Can two of my apps see the same sub?

Yes, if they use the same sector identifier: the same redirect host, or the same `sector_identifier_uri`.

### Is a pairwise sub a secret?

No. Treat it as an opaque, stable ID for your own records. Its protection comes from being different at every site, not from being hidden.

## How Upvera does it

Upvera ID is an OpenID Connect provider at `https://id.upvera.co`, and its discovery document lists only `pairwise`. Every site receives a stable `sub` of 22 characters. Web and mobile clients of the same startup on the same domain see the same value; another startup sees the same person under a different value.

Email is off by default and comes only if the person turns it on. Instead of more personal data, the `upvera:human` claim tells the site the person's proof-of-personhood tier and the month it was reached. Client registration opens in the Upvera app with its next release; endpoints and claims are documented on [upvera.dev](https://upvera.dev/sign-in/). The design is covered in the [Upvera ID report](/identity/).

## Sources

-   [OpenID Connect Core 1.0, section 8: subject identifier types](https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes)
-   [OpenID Connect Core 1.0, section 8.1: pairwise identifier algorithm](https://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg)
-   [OpenID Connect Core 1.0, section 5.7: claim stability and uniqueness](https://openid.net/specs/openid-connect-core-1_0.html#ClaimStability)
-   [OpenID Connect Dynamic Client Registration 1.0: client metadata](https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata)
-   [OpenID Connect Discovery 1.0: provider metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata)
-   [Sign in with Upvera](https://upvera.dev/sign-in/)
-   [Upvera ID discovery document](https://id.upvera.co/.well-known/openid-configuration)

## Related

[UTR-26-04**Upvera ID**One person. A different key for every door.System reportRead](/identity/)[Tool**Check a domain's TXT records and verify you own it**Look up any domain's TXT records from your browser, see which verification tokens are live, and fix the mistakes that make verification fail.FoundersUse it](/guides/dns-txt-verification/)[UTR-26-05**Trust Signals**Server time, bots that never count, no small groups, one number everywhere.System reportRead](/trust/)
