# Email attestation

Optional. If your app has already verified a user's email address, you can assert that to Yodl and the user skips Yodl's own email OTP during registration.

:::warning
Email attestation is **granted per integration, by us**, and is off by default. It is a separate switch from the one that enables your integration. Until we turn it on, a valid token does nothing and the user gets the OTP. Ask for it during onboarding.
:::

## How it works

1. Your backend signs a short-lived JWT asserting "this wallet belongs to this email".
2. Your app passes it into the Yodl registration step as `integrationEmailToken`, alongside the wallet's own Yodl token from the wallet-signature step.
3. We fetch your public key from your JWKS URL, verify the token, and register the user with the email already verified.

These are two different tokens from two different issuers: the wallet token proves the wallet, yours asserts the email.

```mermaid
sequenceDiagram
    autonumber
    actor U as User
    participant App as Your app
    participant BE as Your backend
    participant Y as Yodl

    U->>App: Starts registration
    App->>BE: Ask for an attestation token
    BE-->>App: JWT, iss = your integration address
    Note over App,Y: The wallet separately signs SIWE, producing the wallet token
    App->>Y: register with walletToken + integrationEmailToken
    Y->>BE: Fetch your JWKS URL
    BE-->>Y: Public keys
    Y-->>App: Registered, email already verified
```

Two tokens, two issuers, one request. Yours travels as `integrationEmailToken`; the wallet's proves control of the address in `sub`.

**Every failure is a fallback, not an error.** An expired token, an unknown `kid`, a replayed `jti`, or an email that already belongs to another Yodl account all send the user through the normal OTP flow. You cannot rely on the attestation path always being taken.

## What you register with us

| Item | Notes |
| --- | --- |
| JWKS URL | Where you publish your public keys. **HTTPS only** — an `http://` URL is treated as no key material at all |
| Integration address | Already registered; it doubles as your `iss` |

We fetch and cache the key set and handle `kid` rotation, so publishing a new key and signing with it needs no coordination. Keep old keys in the set until tokens signed with them have expired.

## The claim set

```json [Header]
{
  "alg": "EdDSA",
  "kid": "wallet-2026-07"
}
```

```json [Payload]
{
  "iss": "0x5f2f6f387f49f7cfe0f6f302ff7f6ba6748b6ff1",
  "aud": "api.yodl.me",
  "sub": "0x000000000000000000000000000000000000c0de",
  "email": "user@example.com",
  "email_verified": true,
  "iat": 1785153600,
  "exp": 1785153720,
  "jti": "01J8Z6TESTVECTOR0000000001"
}
```

| Claim | Required | Value |
| --- | --- | --- |
| `alg` (header) | yes | `EdDSA` or `ES256`. Nothing else, ever |
| `kid` (header) | yes | Non-empty. Must match a key in your JWKS |
| `iss` | yes | Your integration address |
| `aud` | yes | `api.yodl.me` |
| `sub` | yes | **The user's wallet address** — not the email |
| `email` | yes | The address you have verified |
| `email_verified` | yes | Exactly boolean `true` |
| `iat` | yes | Issued-at, unix seconds. Must not be in the future |
| `exp` | yes | Expiry, unix seconds. At most 5 minutes after `iat` |
| `jti` | yes | Unique per token, single use |

### `sub` is the wallet, not the email

This is the part people get wrong first, so it is worth dwelling on.

The token's primary assertion is a **binding between a wallet and an email**. Putting the wallet in `sub` is what makes that binding part of the token's identity rather than a pair of loose attributes: a token minted for one wallet cannot be replayed against another.

We check `sub` against the wallet the caller has *already* proved control of in the signature step. If they differ, the token is rejected. So you can only ever speak about a wallet the user is currently proving they hold — you cannot attach an email you verified to a wallet you do not control.

Likewise, `email` is checked against the address actually being registered. A mismatch is rejected, and — deliberately — does *not* burn the token's `jti`, so a token stays usable for the address it was really minted for.

### Algorithms

`EdDSA` (Ed25519) and `ES256` (P-256) only. The allowlist is applied before anything else, so the token header can never widen it and `alg: none` never gets past the first check.

### Lifetime

`exp - iat` must be **at most 300 seconds (5 minutes)**, and `exp` must be after `iat`. We enforce the cap ourselves rather than trusting the issuer to keep it short.

`iat` must not be in the future either. A short window placed a week from now would verify today and keep its OTP-bypass authority until that far-future `exp` — the cap constrains the window's width, not where you put it.

There is a 30-second clock tolerance on both boundaries, so small clock skew between your server and ours is fine. Do not rely on it for anything else.

Mint tokens on demand, at the moment the user starts registration. Two minutes is a comfortable `exp`. Do not mint them in advance and cache them.

### `jti`

Single use, scoped to your integration. We record it on first successful use and reject the second. Use a UUID or a ULID; anything with real entropy per token is fine. Never reuse one, and never derive it from the user or the email.

The `jti` is only consumed by a token we would otherwise accept, so a token rejected on a mismatched email or an expired window has not burned its identifier.

## Verification order

We check in this order, and what a failure means depends on where it happened.

:::steps
##### Algorithm allowlist

The header never gets to pick the key type.

##### Registry gates

Your integration must be registered, enabled, cleared for email attestation, and have key material. All of this happens *before* any signature is checked — which is why a correctly signed token can still fail if attestation isn't switched on for you.

##### `kid` resolution

Resolved against your JWKS.

##### Signature, `iss` and `aud`

The cryptographic check, plus issuer and audience.

##### Lifetime

The 5-minute cap, and where the window sits relative to now.

##### The attested facts

`email_verified`, `email`, and `sub` against the wallet.

##### `jti`

Last, so only a token we would otherwise accept is spent.
:::

:::tip
The ordering is the useful part when debugging. A failure at step 2 means a registration or enablement problem, not a signing problem — so check with us before re-deriving keys.
:::

## A minimal signing server

Node and TypeScript, using [`jose`](https://github.com/panva/jose): a JWKS endpoint, and an endpoint that mints a token for a user your app has already authenticated.

Generate the keypair once, out of band:

```ts [generate-keypair.ts]
import { exportJWK, exportPKCS8, generateKeyPair } from 'jose'

const { publicKey, privateKey } = await generateKeyPair('EdDSA', { extractable: true })

console.log(await exportPKCS8(privateKey))               // -> your secret store
console.log(JSON.stringify(await exportJWK(publicKey)))  // -> your JWKS
```

Then the server:

```ts [server.ts]
import { randomUUID } from 'node:crypto'
import express from 'express'
import { importPKCS8, SignJWT } from 'jose'

const KID = 'wallet-2026-07'
const ALG = 'EdDSA'
const INTEGRATION_ADDRESS = '0x5f2f6f387f49f7cfe0f6f302ff7f6ba6748b6ff1'
const TOKEN_LIFETIME_SECONDS = 120

// Private key from your secret manager. Never from a bundled file, never from the app.
const privateKey = await importPKCS8(process.env.YODL_ATTESTATION_PRIVATE_KEY!, ALG)

// The public half, as a JWK, with the kid you sign under.
const publicJwk = { ...JSON.parse(process.env.YODL_ATTESTATION_PUBLIC_JWK!), kid: KID, alg: ALG, use: 'sig' }

const app = express()

// This is the URL you register with us.
app.get('/.well-known/jwks.json', (_req, res) => {
  res.json({ keys: [publicJwk] })
})

app.post('/yodl/email-attestation', async (req, res) => {
  // YOUR session, YOUR verified email, YOUR wallet binding. Nothing here is taken
  // from the request body: a caller must not get to choose the email or the wallet.
  const user = await requireAuthenticatedUser(req)
  if (!user.emailVerified) return res.status(403).json({ error: 'email not verified' })

  const now = Math.floor(Date.now() / 1000)

  const token = await new SignJWT({ email: user.email, email_verified: true })
    .setProtectedHeader({ alg: ALG, kid: KID })
    .setIssuer(INTEGRATION_ADDRESS)
    .setAudience('api.yodl.me')
    .setSubject(user.walletAddress)
    .setIssuedAt(now)
    .setExpirationTime(now + TOKEN_LIFETIME_SECONDS)
    .setJti(randomUUID())
    .sign(privateKey)

  res.json({ token })
})
```

Three constraints on that endpoint:

* **The private key must not ship in the mobile app.** Anyone who extracts it can assert any email for any wallet. Sign on your server.
* **`exp` must be short.** 120 seconds is enough; 5 minutes is the ceiling we accept. Mint on demand.
* **`jti` must be unique per token.** Reuse a value and the second, legitimate use is the one rejected.

## Getting the token to us

If you use the React Native SDK, you do not build the handoff yourself. Give the component a function and the hosted flow calls it at the moment it registers — after the wallet signature, so the address it hands you is the one it will bind. `<YodlSignup />` is the usual place, but `<YodlPayment />` and `<YodlDashboard />` take the same prop, since a wallet with no Yodl account can be asked to register there too:

```tsx [SignupScreen.tsx]
<YodlSignup
  fetchEmailAttestation={async ({ address, signal }) => {
    const response = await fetch('https://api.yourwallet.com/yodl/email-attestation', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session}` },
      body: JSON.stringify({ walletAddress: address }),
      signal,
    });
    if (!response.ok) return null;

    const { token, email } = await response.json();
    return { token, email };
  }}
/>
```

Hand the same function to `<YodlPayment />` when your app has no separate signup step and the account gets created mid-payment — the demo integration does exactly this:

```tsx [PayScreen.tsx]
<YodlPayment qrData={qrData} fetchEmailAttestation={fetchEmailAttestation} />
```

Yodl asks only when a flow actually registers an account, so on a wallet that already has one the function is never called and the prop costs nothing.

That is why the endpoint above takes no wallet from the request body: the SDK tells you which address to sign for, and your session tells you which email. Neither is caller-controlled — and the `email` you hand back is what we register, so it has to be the one you signed for. The hosted page has no way to suggest one: the request carries the wallet and nothing else.

:::tip
Being called per attempt is the point. Each one gets an unspent `jti` and a full lifetime, so a user who takes their time signing — or retries — still gets the fast path. Do not cache what you return.
:::

Returning `null`, throwing, or taking longer than 30 seconds all mean the same thing: the user verifies by code, exactly as if you had never integrated this. See [the signup flow](/sdk/react-native/signup#skipping-email-verification).

Integrating without our SDK? Send the token as `integrationEmailToken` alongside `email` and the wallet token in the registration request.
