AT Protocol Cryptography Notes#

JWT Service Authentication#

AT Protocol uses signed JWTs for inter-service authentication. When a service (like a feed generator) receives a request from Bluesky, the request includes a JWT in the Authorization: Bearer <token> header.

JWT Structure#

Required fields:

  • iss: issuer - the requester's DID (e.g., did:plc:abc123)
  • aud: audience - the service DID (e.g., did:web:zig-bsky-feed.fly.dev)
  • exp: expiration - UNIX timestamp (~60 seconds recommended)

Optional fields:

  • iat: issued at timestamp
  • lxm: lexicon method (NSID of the endpoint being called)
  • jti: unique nonce to prevent replay attacks

Signing Algorithms#

The JWT alg header indicates the key type:

  • ES256K for secp256k1 (k256) keys
  • ES256 for P-256 (p256) keys

The signature is computed over base64url(header).base64url(payload) using the account's signing key (same key used for repo commits).

DID Document and Public Keys#

To verify a JWT signature, the receiving service must:

  1. Parse the JWT to extract the issuer DID
  2. Resolve the DID document from plc.directory (for did:plc) or .well-known/did.json (for did:web)
  3. Extract the public key from the #atproto verification method
  4. Verify the signature

Example DID document:

{
  "id": "did:plc:abc123",
  "verificationMethod": [{
    "id": "did:plc:abc123#atproto",
    "type": "Multikey",
    "publicKeyMultibase": "zQ3sh..."
  }]
}

Multibase and Multicodec Encoding#

Public keys are encoded using:

  1. Multibase: prefix indicating encoding (e.g., z = base58btc)
  2. Multicodec: prefix indicating key type (varint encoded)

Multicodec Key Type Prefixes#

Multicodec uses unsigned LEB128 (varint) encoding:

Key Type Decimal Varint Bytes
secp256k1-pub 231 (0xe7) 0xe7 0x01
p256-pub 4608 (0x1200) 0x80 0x24

Important: Values >= 128 require 2+ bytes in varint encoding. The high bit indicates continuation.

Decoding a Multibase Key#

Example: zQ3shpqvKe7jKxYdPBoJHq9VVNLoqaDkL7GTtsX16eqFKXFjS

  1. Strip z prefix (base58btc)
  2. Base58 decode the rest
  3. First 2 bytes: 0xe7 0x01 = secp256k1-pub
  4. Remaining 33 bytes: compressed SEC1 public key (starts with 0x02 or 0x03)

Bug We Fixed#

The original code assumed secp256k1's multicodec prefix was 1 byte (0xe7), but it's actually 2 bytes (0xe7 0x01) because 231 >= 128 requires varint continuation.

This caused signature verification to fail because we passed a 34-byte "key" (with the extra 0x01 byte prepended) instead of the correct 33-byte compressed public key.

References#