IA Attestation (Kind 35522)
As part of Zapf's open identity model, a Kind 35522 (IA Attestation) is the cryptographic proof generated by an Identity Authority (IA) stating: "I have verified that Nostr Public Key X owns LIDP Account Y."
This event is generated by the IA server immediately upon completing a successful verification flow and is published to the IA's own relays.
Event Structure
{
"kind": 35522,
"content": "",
"tags": [
["d", "<connection_key>"],
["p", "<user_pubkey>"],
["lidp", "<lidp_name>"],
["evidence", "<verification_payload>"],
["expiration", "<unix_timestamp>"]
],
"pubkey": "<ia_pubkey>",
"created_at": "<unix_timestamp>",
"id": "...",
"sig": "..."
}
Required Tags
| Tag | Format | Description |
|---|---|---|
d | ["d", "<connection_key>"] | Deterministic connection key: SHA256(lidp_name:lidp_id). |
p | ["p", "<hex>"] | The target Nostr public key that proved ownership. |
lidp | ["lidp", "<string>"] | The name of the Legacy Identity Provider (e.g., discord, email). |
evidence | ["evidence", "<string>"] | Verification payload containing proof of identity (see below). |
expiration | ["expiration", "<int>"] | NIP-40 ↗ expiration timestamp. Default 90 days from issuance (IA_ATTESTATION_EXPIRY_DAYS). Set ExpirationDays: 0 to omit expiration. |
The evidence Tag
The evidence tag contains proof that the IA used to verify the user's LIDP account. The value is always a cleartext JSON string (v1 format). auth_type is always "public_post" — all supported verification modes require the user to publish the challenge token publicly.
| Field | Type | Description |
|---|---|---|
version | number | Always 1. |
lidp | string | The LIDP name (e.g., "discord"). |
auth_type | string | Always "public_post". |
user_id | string | The normalized LIDP account identifier (e.g., Discord user ID). |
username | string | The provider handle at verification time (e.g., "joyosar"). |
verified_at | number | Unix timestamp when the verification was completed. |
evidence_url | string | URL of the public post or profile where the challenge appeared. |
challenge | string | The npv1… challenge token that was published at evidence_url. |
pre_auth_code | string | The raw pre-auth code used to generate challenge. Required for cross-IA cryptographic verification. |
The evidence tag enables Evidence Sharing: since the verification is backed by a publicly accessible URL, any IA can independently re-verify the same identity by fetching evidence_url and confirming the challenge is present.
Evidence JSON Schema
A complete v1 evidence payload looks like this:
{
"version": 1,
"lidp": "discord",
"auth_type": "public_post",
"user_id": "1254093577051574374",
"username": "joyosar",
"verified_at": 1779219590,
"evidence_url": "https://discord.com/channels/.../1506380827804831834",
"challenge": "npv11qqsykd7ufyvfjl9qasdgtrz02jsv97l9atdnqc0vz8wsytxqxn9v6pqzvtph4",
"pre_auth_code": "feb7dee63337"
}
This JSON is stored as-is in the evidence tag value.
The npv1 Challenge Token
The challenge field contains a bech32-encoded TLV token with the npv1 prefix. It cryptographically binds the challenge to a specific user+session, preventing replay attacks across sessions.
TLV Structure
type=0x00 || length=0x20 || value=SHA256(nostrPubkeyBytes || preAuthCodeBytes)
- Type
0x00: session hash (32 bytes). - Length
0x20: always 32 (hex20). - Value:
SHA256(hex.Decode(p_tag_pubkey) || []byte(pre_auth_code)).
The resulting 34-byte TLV is bech32-encoded with the npv1 human-readable part.
Cross-IA Challenge Binding
When a second IA wants to re-attest an identity from existing evidence, it must verify the challenge is genuinely bound to the claimed user and session — not replayed from a different session. The algorithm:
- Bech32-decode the
challengetoken to get the 34-byte TLV payload. - Strip the 2-byte TLV header (
0x00 0x20) → 32-byte session hash. - Compute
SHA256(hex.Decode(p_tag_pubkey) || []byte(evidence.pre_auth_code)). - Assert both hashes match.
Step 4 is what separates a genuine session-bound challenge from a replayed token. Without pre_auth_code in the evidence, step 3 is impossible — this is why pre_auth_code must always be included in the evidence payload.
Event Lifecycle
- Creation: User verifies identity with the IA. IA signs Kind 35522.
- Publishing: IA publishes the event to its designated relays. The session moves to
confirmedstatus. The LIDP→Nostr routing link is NOT yet active at this point. - Linking: The IA returns the raw Kind 35522 event to the user's client. The client extracts the event ID and IA relay URL, builds an
etag referencing the attestation, and publishes a Kind 35521 event. The full Kind 35522 is NOT embedded — consumers fetch it from the IA relay on demand. - Activation: After publishing Kind 35521, the client notifies the IA that the connection event is live. The IA writes the
Identityrecord to its store, making the LIDP→Nostr routing link live. The session moves toactivestatus. Routing is only live after this step. The mechanism for this notification is IA-implementation-defined; the protocol only requires that routing is not activated until the IA receives this signal. - Revocation: If a user disconnects their account, the IA publishes a Kind 5 (Event Deletion) targeting the Kind 35522 ID. This instantly invalidates the attestation during a Deep Check. If the session was
active, the IA also removes theIdentityrouting record from its store.