Skip to main content

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

TagFormatDescription
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.

FieldTypeDescription
versionnumberAlways 1.
lidpstringThe LIDP name (e.g., "discord").
auth_typestringAlways "public_post".
user_idstringThe normalized LIDP account identifier (e.g., Discord user ID).
usernamestringThe provider handle at verification time (e.g., "joyosar").
verified_atnumberUnix timestamp when the verification was completed.
evidence_urlstringURL of the public post or profile where the challenge appeared.
challengestringThe npv1… challenge token that was published at evidence_url.
pre_auth_codestringThe 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 (hex 20).
  • 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:

  1. Bech32-decode the challenge token to get the 34-byte TLV payload.
  2. Strip the 2-byte TLV header (0x00 0x20) → 32-byte session hash.
  3. Compute SHA256(hex.Decode(p_tag_pubkey) || []byte(evidence.pre_auth_code)).
  4. 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

  1. Creation: User verifies identity with the IA. IA signs Kind 35522.
  2. Publishing: IA publishes the event to its designated relays. The session moves to confirmed status. The LIDP→Nostr routing link is NOT yet active at this point.
  3. 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 e tag 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.
  4. Activation: After publishing Kind 35521, the client notifies the IA that the connection event is live. The IA writes the Identity record to its store, making the LIDP→Nostr routing link live. The session moves to active status. 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.
  5. 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 the Identity routing record from its store.