> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useduro.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Universal Identity

> One phone, verified over WhatsApp, that owns a payment wallet reusable across every Duro merchant. The second big bet, in detail.

The [thesis](/the-thesis) made the argument; this page is the implementation. A `CustomerIdentity` is a person, keyed by phone number, living in the **core schema** — deliberately *not* scoped to any tenant. It owns saved payment methods that work everywhere.

## Why phone, why WhatsApp

Email is a weak identity in Nigeria — barely checked, easily mistyped. The phone number is the real primary key for a person, and WhatsApp is the channel everyone has and trusts. So Duro verifies ownership of the phone with a one-time code over the **WhatsApp Cloud API**, and the verified phone becomes the wallet's key.

## The OTP flow

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant C as Customer
    participant ID as IdentityService
    participant DB as core schema
    participant WA as WhatsApp Cloud API

    C->>ID: POST /identity/otp { phone }
    ID->>DB: rate-limit check (≤5 / 15min)
    ID->>ID: code = randomInt(6 digits)
    ID->>DB: store sha256(code), expiresAt +10min
    ID->>WA: send authentication template
    WA-->>C: 6-digit code
    C->>ID: POST /identity/verify { phone, code }
    ID->>DB: find active challenge, attempts < 5
    ID->>ID: timing-safe compare hash
    ID->>DB: consume challenge, upsert identity (verifiedAt)
    ID-->>C: { token, identity }
```

Security properties, all real:

* **The code is never stored in clear** — only `sha256(code)`. Verification is a timing-safe hash compare (`Hash.verifyHashedSecret`).
* **Brute force is bounded** — 6 digits × 5 attempts per challenge × 5 challenges per 15 minutes is negligible search space, and rate-limited besides.
* **Codes expire** in 10 minutes and are single-use (`consumedAt`).
* **The identity token is marked** `keyId: identity` so it can't masquerade as a tenant credential — present it to `/v1` and it resolves to an identity id, not a tenant, and fails. [See request lifecycle →](/architecture/request-lifecycle)

## The cross-merchant wallet

```mermaid theme={null}
flowchart TB
    PHONE["phone: 2348012345678<br/>(verified)"] --> IDENT["CustomerIdentity"]
    IDENT --> M1["SavedPaymentMethod<br/>Visa ••4242"]
    IDENT --> M2["SavedPaymentMethod<br/>direct-debit mandate"]
    M1 -.usable at.-> A["Merchant A"]
    M1 -.usable at.-> B["Merchant B"]
    M1 -.usable at.-> C["Merchant C"]
```

A `SavedPaymentMethod` hangs off the identity, not a tenant. The presenter exposes only `rail`, `brand`, and `last4` — the gateway token is never returned over the API. When the customer pays at any merchant, Duro links a tenant-scoped `Customer` to the same identity by phone, so the merchant sees *their* customer while the wallet stays shared.

## Re-login returns the same person

Because the key is the phone, a second OTP for the same number resolves to the **same identity** — verified live in the build. That's what makes "one portal, every merchant" possible: there's exactly one identity per phone, ever.

Next: the [customer portal](/identity/customer-portal) built on top of this.
