Attestations vs. Credentials: Making Claims Interoperable

At the core of W3C standards are the features they unlock, not only for the users themselves but for the developers integrating them into their applications.

Attestations vs. Credentials: Making Claims Interoperable

In our Verifiable Credentials tutorial and Data Provenance blog article, we discussed how some teams building on Ceramic use verifiable claims to make assertions about their users or allow their users to make claims about other users or non-user entities. At the time of writing this article, we’re seeing momentum build and excitement aggregate along particular claim standards (such as W3C Verifiable Credentials and attestations using the Ethereum Attestation Service). At the core of these standards are the features they unlock, not only for the users themselves but (importantly) the developers integrating them into their applications.

So what are these features? What are the differences in capabilities between standards currently being used? What trade-offs should developers consider before choosing one framework over another? Is there a world where they can be used together?

As you might’ve guessed, these are the topics we’ll cover below. More specifically, we will observe how these standards compare in the world of off-chain self-sovereign identity (SSI).

Common Features of Verifiable Claims

Schemas

If verifiable claims themselves are building blocks of online trust and reputation, the schemas those claims use provide their necessary structure. This ensures that two separate instances of a claim that fall into the same claim “family” can be predictably and reliably interpreted.

For example, if you were using a courseCompleted schema in an academic setting that used a required boolean field (representing whether or not a student passed a course) and a required string field (to identify the course by name), it would be hard to consume and interpret data instances that had additional unwanted fields or were altogether missing one of those two required fields.

Take a look at how the data schemas W3C section articulates the need for schemas in comparison to a similar section on the EAS docs site. While the implementation differs, both point out that schemas are essential for enforcing data conformity within a given collection.

Public Schema Registries

A schema registry is another commonality between these emergent standards, with the connecting tissue being a place to publicly reference and house schemas. In the world of decentralization and SSI, the registry plays a particularly compelling role because it enables disparate applications to build on shared data models due to the predictability of the shape of each data instance.

For instance, if Application A is a Ph.D. program, it can validate credentials created by Application B, a four-year university, and issue credentials to the same courseCompleted schema. Employer A cares about credentials issued by both Application A and Application B.

For entities issuing claims, these registries both serve the purpose of helping those teams ensure their data conforms to the schemas they utilize, while (just as importantly) functioning as a discovery mechanism for types of SSI. Entities consuming and validating those claims use those schema definitions to validate whether a user has cryptographically signed a given statement, making it essentially impossible for bad actors to tamper with those claims.

Registries like Serto service this need for applications using Verifiable Credentials, whereas Verax and EAS use immutable on-chain registries where anyone can deploy or reference schemas for attestations.

Credential Metadata + Body

The most obvious commonality across standards is the assertion itself (presumably in the form of the schema it is using), alongside other important metadata such as expiration dates or revocation status. The form and corresponding values of the credential itself, for example, are checked against the expected form of the schema by issuers, thus making it easy to omit nonconforming data altogether.

Proof (Signatures)

Whereas a public ledger of record like a blockchain verifiably reveals the behavior of a user address simply by observing its transaction history (thus simplifying the observability of on-chain attestations so long as a user’s account hasn’t been hacked), verifiable claims produced elsewhere must include cryptographic proofs that verify the provenance of those assertions. Similar to the role of public schema registries, attached proofs ensure that consuming entities can reliably verify that a given user has made some claim, agnostic of where the claim was stored or when it was produced.

For example, the EAS off-chain module incurs an EIP-712 signTypedData request that packages the contents of the attestation as method arguments, encodes, hashes, and requests a user signature over the hash of that data. Veramo’s EIP-712 module (just one method of producing a signature with Veramo) similarly requests and yields an EIP-712-conforming payload over the Verifiable Credential being signed.

Finally, public data ledgers like Ceramic are architected on mutable data streams, allowing users to create and mutate their streams as much as they desire under a single authenticated session. Users might choose to make claims about themselves or others without an additional attestation or framework layer.

Oamo, for example, is a platform build on Ceramic that helps individuals own and monetize their data by granting access to it to other organization and companies in exchange for rewards. Oamo generates claims authored by the application itself (as opposed to user-authored claims). For example, Oamo would issue a certain claim type after a user has connected their Twitter account. Unlike teams like Gitcoin that are storing Verifiable Credentials on Ceramic, Oamo uses predefined ComposeDB schemas to provide claim structure, with no additional Verifiable Credential or attestation frameworks or layering on top. You can view one of these model definitions here.

Similar to a blockchain, if a data consumer is aware that claims are being authored by users they care about in this type of environment, they can choose to index on those streams and can rest assured of the provenance and lineage of that user data. Ease of portability to a different environment later on, however, might be an issue under this paradigm.

Decentralized Identifiers

If you’re reading this, chances are you already know the whole spiel on DIDs, so we won’t spend too much time here. The important key to note here is how these identifiers are viewed as an essential pillar of SSI. Similar to how issuers and consumers of verifiable claims containing proofs can collaborate on shared data due to the inherent characteristics of those claims, DIDs ensure that individual users can take their identity from one context to another without the permission or awareness of some central actor.

Choosing between Claim Types

Despite their similarities, developers who choose to incorporate claims into their applications must consider the trade-offs and differences in experience each method offers. For the purpose of this comparison, I’m intentionally isolating “off-chain” claims (produced either by signed meta transactions, or claims generated on a public data ledger like Ceramic, both of which incur no cost to the issuer).

Given this brief comparison, it’s easy to understand why many teams choose to build high-portability claim types like Verifiable Credentials on top of a public data network like Ceramic. This combination works in complimentary fashion to enable easy portability and verifiability (when using a framework), alongside the availability and discoverability benefits of a network.

Making Claim Standards Interoperable

Given the similarities between the two tamper-evident claim methods, developers might want to give their users optionality by allowing them to choose which one to use. For teams building on Ceramic and utilizing ComposeDB, they can offer their users this flexibility without degrading the efficiency of their query logic. By using interfaces, developers can define a “family” of data under a verifiableClaim (or other preferred name) interface, and define the low-hanging similarities between the two standards as interface subfields. The standard-specific subfields can be used as differentiators defined within higher-level types.

For example, let’s say an application wants to allow their users to create accountTrust claims that point to a DID and contain a boolean value representing trust or distrust (here’s a VC definition and EAS schema that do exactly this). Here’s a resulting payload from each:

// Credential

{
    "issuer": "did:pkh:eip155:1:0x06801184306b5eb8162497b8093395c1dfd2e8d8",
    "@context": [
        "https://www.w3.org/2018/credentials/v1",
        "https://beta.api.schemas.serto.id/v1/public/trusted-reviewer/1.0/ld-context.json"
    ],
    "type": [
        "VerifiableCredential",
        "Trusted"
    ],
    "credentialSchema": {
        "id": "https://beta.api.schemas.serto.id/v1/public/trusted/1.0/json-schema.json",
        "type": "JsonSchemaValidator2018"
    },
    "credentialSubject": {
        "isTrusted": true,
        "id": "did:pkh:eip155:1:0xcc2158d7e1b0fffd4db6f51e35f05e00d8fe30b2"
    },
    "issuanceDate": "2023-12-05T21:03:03.061Z",
    "proof": {
        "verificationMethod": "did:pkh:eip155:1:0x06801184306b5eb8162497b8093395c1dfd2e8d8",
        "created": "2023-12-05T21:03:03.061Z",
        "proofPurpose": "assertionMethod",
        "type": "EthereumEip712Signature2021",
        "proofValue": "0x47fadf4bab9c0d111b6bf304eb2c72e6419c636f7b117761ce5cf4926a79074e073e2560b90d78230deac06a7afc705813f3f403fa51967e2da0e7783d4dae0d1b",
        "eip712": {
            "domain": {
                "chainId": 1,
                "name": "VerifiableCredential",
                "version": "1"
            },
            "types": {
                "EIP712Domain": [
                    {
                        "name": "name",
                        "type": "string"
                    },
                    {
                        "name": "version",
                        "type": "string"
                    },
                    {
                        "name": "chainId",
                        "type": "uint256"
                    }
                ],
                "CredentialSchema": [
                    {
                        "name": "id",
                        "type": "string"
                    },
                    {
                        "name": "type",
                        "type": "string"
                    }
                ],
                "CredentialSubject": [
                    {
                        "name": "id",
                        "type": "string"
                    },
                    {
                        "name": "isTrusted",
                        "type": "bool"
                    }
                ],
                "Proof": [
                    {
                        "name": "created",
                        "type": "string"
                    },
                    {
                        "name": "proofPurpose",
                        "type": "string"
                    },
                    {
                        "name": "type",
                        "type": "string"
                    },
                    {
                        "name": "verificationMethod",
                        "type": "string"
                    }
                ],
                "VerifiableCredential": [
                    {
                        "name": "@context",
                        "type": "string[]"
                    },
                    {
                        "name": "credentialSchema",
                        "type": "CredentialSchema"
                    },
                    {
                        "name": "credentialSubject",
                        "type": "CredentialSubject"
                    },
                    {
                        "name": "issuanceDate",
                        "type": "string"
                    },
                    {
                        "name": "issuer",
                        "type": "string"
                    },
                    {
                        "name": "proof",
                        "type": "Proof"
                    },
                    {
                        "name": "type",
                        "type": "string[]"
                    }
                ]
            },
            "primaryType": "VerifiableCredential"
        }
    }
}

// Attestation

{
    "domain": {
        "name": "EAS Attestation",
        "version": "0.26",
        "chainId": 1,
        "verifyingContract": "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587"
    },
    "primaryType": "Attest",
    "message": {
        "recipient": "0xcc2158d7e1b0fffd4db6f51e35f05e00d8fe30b2",
        "expirationTime": 0,
        "time": 1701810283,
        "revocable": true,
        "version": 1,
        "nonce": 0,
        "schema": "0x776c6c1d76055522753787b3abfdbeeff262cda35eebecaf83059b738698ef62",
        "refUID": "0x0000000000000000000000000000000000000000000000000000000000000000",
        "data": "0x0000000000000000000000000000000000000000000000000000000000000001"
    },
    "types": {
        "Attest": [
            {
                "name": "version",
                "type": "uint16"
            },
            {
                "name": "schema",
                "type": "bytes32"
            },
            {
                "name": "recipient",
                "type": "address"
            },
            {
                "name": "time",
                "type": "uint64"
            },
            {
                "name": "expirationTime",
                "type": "uint64"
            },
            {
                "name": "revocable",
                "type": "bool"
            },
            {
                "name": "refUID",
                "type": "bytes32"
            },
            {
                "name": "data",
                "type": "bytes"
            }
        ]
    },
    "signature": {
        "v": 27,
        "r": "0x9957ad308ba7e4355092b66e3fd26f56e35ea93f6e5149304a4a063ff4732efb",
        "s": "0x38aa8ae9f09dbc050b5bc7f8ecd9ed92bf7e3cdca34feb3e2789fa22b298b202"
    },
    "uid": "0xfd7bad067b12dc55865cdd3c9c46b331a31c6a872bd75ca5fc6a8a623134f28b",
    "account": "0x06801184306b5eb8162497b8093395c1dfd2e8d8"
}

You’ll notice a few things right away that underline differences we’ll have to account for when saving to ComposeDB. For example, the issuer and [credentialSubject.id](<http://credentialSubject.id>) fields within our Verifiable Credential use DIDs, whereas the account (akin to issuer) and message.recipient within our attestation use Eth addresses. The signatures are also different in observing the signature object within our Attestation instance vs. our proof.proofValue credential field.

Let’s assume we want to make these work as-is without deploying new Verifiable Credential or Attestation schemas.

Layer 0 (Broadest)

Since we’re allowing our users to both author their own claims and corresponding Ceramic documents, our broadest-level interface can therefore tie together the claim creator with the claim recipient:

## our broading claim type

interface VerifiableClaim 
@createModel(description: "A verifiable claim interface")
{
  controller: DID! @documentAccount
  recipient: DID! @accountReference
}

We will need to account for the differences between the payloads and how they must be saved. For example:

//attestations

recipient: "${"did:pkh:eip155:1:" + attestation.message.recipient}"

//VCs

recipient: "${credential.credentialSubject.id}"

Layer 1

Our next level down entrypoint can therefore dictate the claim type that was used:

## our overarching VC interface that acts agnostic of our proof type

interface VerifiableCredential implements VerifiableClaim
  @createModel(description: "A verifiable credential interface")
{
  controller: DID! @documentAccount
  recipient: DID! @accountReference
  issuer: Issuer! 
  context: [String!]! @string(maxLength: 1000) @list(maxLength: 100)
  type: [String!]! @string(maxLength: 1000) @list(maxLength: 100)
  credentialSchema: CredentialSchema!
  credentialStatus: CredentialStatus
  issuanceDate: DateTime!
  expirationDate: DateTime
}

## our overarching Attestation interface that acts agnostic of our proof type

interface Attestation implements VerifiableClaim
@createModel(description: "An attestation interface")
{
  controller: DID! @documentAccount
  recipient: DID! @accountReference
  attester: DID! @accountReference
  trusted: Boolean!
  uid: String! @string(minLength: 66, maxLength: 66)
  schema: String! @string(minLength: 66, maxLength: 66)
  verifyingContract: String! @string(minLength: 42, maxLength: 42)
  easVersion: String! @string(maxLength: 5)
  version: Int!
  chainId: Int! 
  r: String! @string(minLength: 66, maxLength: 66)
  s: String! @string(minLength: 66, maxLength: 66)
  v: Int! 
  types: [Types] @list(maxLength: 100)
  expirationTime: DateTime
  revocationTime: DateTime
  refUID: String @string(minLength: 66, maxLength: 66)
  time: Int! 
  data: String! @string(maxLength: 1000000)
}

You can start to see how these parent definitions open up the possibilities of running queries like this:

query VerifiableClaims {
  verifiableClaimIndex(last: 10) {
    edges {
      node {
        recipient {
          id
        }
        controller {
          id
        }
        ... on VerifiableCredential {
          issuer {
            id
          }
        }
        ... on Attestation {
          attester {
            id
          }
        }
      }
    }
  }
}

Layer 2

We can add a third interface layer that accounts for the differences between proof types:

## generalized JWT proof interface for VCs

interface VCJWTProof implements VerifiableClaim & VerifiableCredential 
  @createModel(description: "A verifiable credential interface of type JWT")
{
  controller: DID! @documentAccount
  recipient: DID! @accountReference
  issuer: Issuer! 
  context: [String!]! @string(maxLength: 1000) @list(maxLength: 100)
  type: [String!]! @string(maxLength: 1000) @list(maxLength: 100)
  credentialSchema: CredentialSchema!
  credentialStatus: CredentialStatus
  issuanceDate: DateTime!
  expirationDate: DateTime
  proof: ProofJWT!
}

type ProofJWT {
  type: String! @string(maxLength: 1000)
  jwt: String! @string(maxLength: 100000)
}

## generalized EIP712 proof interface for VCs

interface VCEIP712Proof implements VerifiableClaim & VerifiableCredential 
  @createModel(description: "A verifiable credential interface of type EIP712")
{
  controller: DID! @documentAccount
  recipient: DID! @accountReference
  issuer: Issuer! 
  context: [String!]! @string(maxLength: 1000) @list(maxLength: 100)
  type: [String!]! @string(maxLength: 1000) @list(maxLength: 100)
  credentialSchema: CredentialSchema!
  credentialStatus: CredentialStatus
  issuanceDate: DateTime!
  expirationDate: DateTime
  proof: ProofEIP712!
}

type Issuer {
  id: String! @string(maxLength: 1000)
  name: String @string(maxLength: 1000)
}

type CredentialStatus {
  id: String! @string(maxLength: 1000)
  type: String! @string(maxLength: 1000)
}

type CredentialSchema {
  id: String! @string(maxLength: 1000)
  type: String! @string(maxLength: 1000)
}

type ProofEIP712 {
  verificationMethod: String! @string(maxLength: 1000)
  created: DateTime! 
  proofPurpose: String! @string(maxLength: 1000)
  type: String! @string(maxLength: 1000)
  proofValue: String! @string(maxLength: 1000)
  eip712: EIP712!
}

type EIP712 {
    domain: Domain! 
    types: ProofTypes!
    primaryType: String! @string(maxLength: 1000)
}

type Types {
  name: String! @string(maxLength: 1000)
  type: String! @string(maxLength: 1000)
}

type ProofTypes {
    EIP712Domain: [Types!]! @list(maxLength: 100)
    CredentialSchema: [Types!]! @list(maxLength: 100)
    CredentialSubject: [Types!]! @list(maxLength: 100)
    Proof: [Types!]! @list(maxLength: 100)
    VerifiableCredential: [Types!]! @list(maxLength: 100)
}

type Domain {
  chainId: Int!
  name: String! @string(maxLength: 1000)
  version: String! @string(maxLength: 1000)
}

We can therefore start to query these fields:

query VerifiableClaims {
  verifiableClaimIndex(last: 10) {
    edges {
      node {
        recipient {
          id
        }
        controller {
          id
        }
        ... on VerifiableCredential {
          issuer {
            id
          }
          ... on VCEIP712Proof {
            context
            proof {
              type
              proofValue
              created
              verificationMethod
              eip712 {
                primaryType
                types {
                  EIP712Domain {
                    name
                    type
                  }
                  CredentialSchema {
                    name
                    type
                  }
                  CredentialSubject {
                    name
                    type
                  }
                  VerifiableCredential {
                    name
                    type
                  }
                }
                domain {
                  chainId
                  name
                  version
                }
              }
            }
          }
        }
        ... on Attestation {
          attester {
            id
          }
        }
      }
    }
  }
}

Layer 3

Our next layer can include the specific values relevant to the claim. We’ve maxed out the layers we want to use for our Attestations (so we’ll define this as a type), while our Verifiable Credentials will detach the proof type from the claim:

interface AccountTrustCredential implements VerifiableClaim & VerifiableCredential  
  @createModel(description: "A verifiable credential interface for account trust credentials")
{
  controller: DID! @documentAccount
  recipient: DID! @accountReference
  issuer: Issuer! 
  context: [String!]! @string(maxLength: 1000) @list(maxLength: 100)
  type: [String!]! @string(maxLength: 1000) @list(maxLength: 100)
  credentialSchema: CredentialSchema!
  credentialStatus: CredentialStatus
  issuanceDate: DateTime!
  expirationDate: DateTime
  credentialSubject: AccountTrustSubject! 
}

type AccountTrustSubject
{
  id: DID! @accountReference
  trusted: Boolean! 
}

type AccountAttestation implements VerifiableClaim & Attestation 
  @createModel(accountRelation: LIST, description: "An account attestation")
  @createIndex(fields: [{ path: ["time"] }])
{
  controller: DID! @documentAccount
  recipient: DID! @accountReference
  attester: DID! @accountReference
  uid: String! @string(minLength: 66, maxLength: 66)
  schema: String! @string(minLength: 66, maxLength: 66)
  verifyingContract: String! @string(minLength: 42, maxLength: 42)
  easVersion: String! @string(maxLength: 5)
  version: Int!
  chainId: Int! 
  r: String! @string(minLength: 66, maxLength: 66)
  s: String! @string(minLength: 66, maxLength: 66)
  v: Int! 
  types: [Types] @list(maxLength: 100)
  expirationTime: DateTime
  revocationTime: DateTime
  refUID: String @string(minLength: 66, maxLength: 66)
  time: Int! 
  data: String! @string(maxLength: 1000000)
  trusted: Boolean!
}

Incorporating into our querying:

query VerifiableClaims {
  verifiableClaimIndex(last: 10) {
    edges {
      node {
        recipient {
          id
        }
        controller {
          id
        }
        ... on VerifiableCredential {
          issuer {
            id
          }
          ... on AccountTrustCredential {
            credentialSubject {
              id {
                id
              }
              trusted
            }
          }
        }
        ... on Attestation {
          ... on AccountAttestation {
            attester {
              id
            }
          }
        }
      }
    }
  }
}

Layer 4 (most specific)

Finally, since we’ve defined our AccountTrustCredential interface as agnostic of our proof type, our final layer will define types that differentiate based on proof:

type AccountTrustCredential712 implements VerifiableClaim & VerifiableCredential & AccountTrustCredential & VCEIP712Proof 
  @createModel(accountRelation: LIST, description: "A verifiable credential of type EIP712 for account trust credentials")
  @createIndex(fields: [{ path: "issuanceDate" }])
  @createIndex(fields: [{ path: "trusted" }]) {
  controller: DID! @documentAccount
  recipient: DID! @accountReference
  issuer: Issuer! 
  context: [String!]! @string(maxLength: 1000) @list(maxLength: 100)
  type: [String!]! @string(maxLength: 1000) @list(maxLength: 100)
  credentialSchema: CredentialSchema!
  credentialStatus: CredentialStatus
  issuanceDate: DateTime!
  expirationDate: DateTime
  credentialSubject: AccountTrustSubject! 
  trusted: Boolean!
  proof: ProofEIP712!
}

type AccountTrustCredentialJWT implements VerifiableClaim & VerifiableCredential & AccountTrustCredential & VCJWTProof 
  @createModel(accountRelation: LIST, description: "A verifiable credential of type JWT for account trust credentials")
  @createIndex(fields: [{ path: "issuanceDate" }])
  @createIndex(fields: [{ path: "trusted" }]) {
  controller: DID! @documentAccount
  recipient: DID! @accountReference
  issuer: Issuer! 
  context: [String!]! @string(maxLength: 1000) @list(maxLength: 100)
  type: [String!]! @string(maxLength: 1000) @list(maxLength: 100)
  credentialSchema: CredentialSchema!
  credentialStatus: CredentialStatus
  issuanceDate: DateTime!
  expirationDate: DateTime
  credentialSubject: AccountTrustSubject! 
  trusted: Boolean!
  proof: ProofJWT!
}

Putting it all together in a query example:

query VerifiableClaims {
  verifiableClaimIndex(last: 10) {
    edges {
      node {
        recipient {
          id
        }
        controller {
          id
        }
        ... on VerifiableCredential {
          issuer {
            id
          }
          ... on AccountTrustCredential712 {
            proof {
              type
              proofValue
              created
              verificationMethod
              eip712 {
                primaryType
                types {
                  EIP712Domain {
                    name
                    type
                  }
                  CredentialSchema {
                    name
                    type
                  }
                  CredentialSubject {
                    name
                    type
                  }
                  VerifiableCredential {
                    name
                    type
                  }
                }
                domain {
                  chainId
                  name
                  version
                }
              }
            }
          }
        }
        ... on Attestation {
          ... on AccountAttestation {
            r
            s
            v
            version
            verifyingContract
            easVersion
            trusted
            attester {
              id
            }
            recipient {
              id
            }
          }
        }
      }
    }
  }
}

You can begin to see how developers who might want to account for multiple claim types, each with sub-options offering different proof types, while still exposing these as queryable under an overarching claim family can start to do interesting things with precision using interfaces.

Developers will still need to keep the reconstruction and validation mechanics in mind, given the deconstruction and alterations made to the data in order to save to ComposeDB and make it interoperable under a VerifiableClaim interface, and the client-side work required to reconstruct the claim into an acceptable format to be verified.

To view and experiment with the example code yourself, visit and clone this repository.