Skip to main content

Issuing a credential

Issuing turns a document and a few details into a credential: a signed, salted fingerprint anchored in the CredentialRegistry, an encrypted copy of the document on IPFS, and a credential package for the holder. Everything secret (the document, the key, the name and student ID) stays in the issuer's browser or goes into the package. The chain receives only hashes, commitments, a record type, a date and the issuer's signature.

This page describes the v0.3 flow on /records/add. The legacy v0.2 form is summarised under Variants.

Who takes part​

PartyRole in this flow
IssuerA wallet registered in the active set's IssuerRegistry and not retired
Issuer's browserHashes, encrypts, derives salts, builds the EIP-712 message, builds the package
Wallet (MetaMask)Signs typed data and sends the transaction
Upload routeChecks the sign-in session and the container, pins it to IPFS through Pinata
CredentialRegistryChecks the signature and fields, stores the anchor, starts the status
StatusRegistryRecords the credential as Active, or marks the old one Superseded on re-issue

Preconditions​

  • NEXT_PUBLIC_CONTRACT_VERSION is v03 and the deployment manifest names the active contract set.
  • The connected wallet is an active issuer. useIssuerSetup sends a visitor with no wallet to /login and a wallet without the issuer role to /records. On v0.3 the check is IssuerRegistry.isIssuer(address) and not retired.
  • The wallet is a plain account (EOA). A contract wallet cannot protect documents ("This account is a contract wallet, which cannot protect documents yet. Use a plain wallet account.") and cannot derive student salts. See Contract-wallet issuers.
  • The system is not paused. While the IssuerRegistry is paused the Issue Record button is disabled and the contract would refuse with SystemPaused.
  • The server has its Pinata and session settings. Without them the upload route answers that it is not configured.

Where it starts​

The issuer dashboard (/dashboard) has the buttons Issue a credential (to /records/add) and Bulk import (to /records/import, see Bulk import). A row's Re-issue action opens /records/add?replaces=<id>.

The page header reads Issue New Record (or Re-issue Record), title Add Academic Record, "Issuing on behalf of" the registered name, with shortcuts Invite a student to link their wallet and Manage your record types. Beside the form a live Preview card shows the record type, the holder's name, the document, whether it is stored ("Encrypted") and the delivery choice ("Linked wallet" or "Package and link only").

The four-part form​

PartFieldsNotes
1 StudentStudent Name, Student ID, and the Check link buttonCheck link asks the wallet for one signature, then shows "Linked to wallet 0x.... A linked credential reaches them." and ticks delivery, or "This student ID is not linked to a wallet yet. Tick the box below and the credential will reach them once they accept an invitation for this ID."
2 CredentialRecord Type, Programme (optional), Award (optional)The list holds the 32 built-in types and the issuer's active custom types, marked "(issuer-defined)". If the custom types cannot be read: "Your own record types could not be loaded, so only the standard types are listed." The page notes that the name, ID, programme and award are not stored on-chain.
3 DocumentDocument file (PDF, PNG or JPEG up to 3.5 MB), button Encrypt and upload; read-only IPFS Hash and Document SHA-256"Your wallet asks for a signature to protect this document. Only you can read the stored copy."
4 DeliveryCheckbox Deliver to the student's linked walletTicked, the credential uses the issuer's stable salt for this student, so it appears in their wallet once they accept an invitation (before or after issuing). Unticked, no wallet can ever receive it.

The submit button is Issue Record ("Your wallet asks you to confirm before anything is written on-chain."). With delivery unticked, a dialog asks first: Issue without delivering to a wallet?, "No wallet will show this credential, now or later. This cannot be changed after issuing.", buttons Issue without delivery and back. A missing required field gives "Please fill in all required fields."

Step by step​

1. Encrypt and upload​

DocumentService.upload (src/services/domain/document-service.ts):

  1. Checks the type, size and the file's leading bytes (assertSupportedFile).
  2. Computes the SHA-256 of the plaintext. This is the document hash that will be anchored, so anyone holding the file can later check it.
  3. Derives the issuer's encryption key (two identical EncryptionKey signatures, once per session) and encrypts the file into a container with a fresh 32-byte document key, wrapped for the issuer's own key only.
  4. Sends the container to POST /api/upload with the header X-Document-SHA256. If the server has no valid session (401 or 403) the browser fetches a nonce, asks the wallet to sign a sign-in message, posts it to /api/auth/verify, and retries once.
  5. Accepts the answer only if blobHash equals the SHA-256 of what it sent and documentHash equals its own hash.

The server checks the session, rate limits (30 uploads per wallet per hour), that the container is not plaintext, that its header names the signed-in wallet, and that the claimed hash matches the header, then pins it. The document key never leaves the browser. Details: Document storage and Upload and documents.

2. Build and sign the credential​

submitRecord (src/app/records/add/submit-record.ts) validates the form and calls CredentialService.issue:

Field signedValue
issuerThe connected wallet
subjectCommitmentsubjectCommitment(issuer, studentId, subjectSalt); subjectSalt is the stable student salt when delivering, otherwise 32 fresh random bytes
documentHashFrom the upload
metadataHashkeccak256 of the RFC 8785 form of the display data plus a fresh metadataSalt
recordTypeThe chosen type id
issuedAtNow, in Unix seconds
validUntil0 (no expiry; the app has no expiry field)
nonce32 random bytes

The stable student salt is keccak256 of a signature over StudentSalt(address issuer, string studentId) (see Enrolment). The wallet then signs the Credential typed data on the domain { name: "Certichain", version: "1", chainId, verifyingContract: CredentialRegistry }. The EIP-712 digest is the credential id.

3. Anchor on chain​

The browser sends issue(credential, signature), or issueReplacing(credential, signature, replacedId, noteHash) for an atomic re-issue. The CredentialRegistry checks, in order:

CheckRevertMessage shown
System not pausedSystemPaused"The system is paused, so new credentials cannot be issued right now. Try again when it restarts."
Caller is credential.issuer and an active issuerNotIssuer"Your account is not registered as an issuer, or is no longer registered."
Record type allowed for this issuerRecordTypeNotAllowed"This record type is not available to you. It may be switched off, or it may belong to another institution."
Non-zero commitment, metadata hash and document hashInvalidCommitment, InvalidMetadataHash, ZeroDocumentHashGeneric failure
issuedAt at most 5 minutes in the futureInvalidIssuedAtGeneric failure
validUntil zero or after issuedAtInvalidValidityGeneric failure
Nonce unused for this issuerNonceAlreadyUsed"This issue request was already used. Please try again."
Signature valid (EOA or ERC-1271)InvalidSignature"The issuer signature was not accepted. Sign with the issuing account."
Id not issued beforeCredentialAlreadyIssued"This exact credential has already been issued."

On success it stores the anchor, calls StatusRegistry.initialise (status Active), and emits CredentialIssued(credentialId, issuer, subjectCommitment, recordType) and CredentialSigned(credentialId, issuer, nonce, signature). The signature lives only in the event; the verify page reads it back from there. The browser checks that the emitted id equals the digest it computed. Other failures show "Failed to add record. Please try again or contact support." Contract details: Credential registry.

4. Disclosure attestation​

After the credential exists, submitRecord asks for one more signature: the issuer's attestation over a salted tree of the display fields, which lets the holder later show some fields without the others. It is optional. If it is refused or fails, the credential stays valid and the card shows "The credential is issued, but the selective disclosure signature was not added. Add it later from the issuer dashboard." See Selective disclosure.

5. The issued card​

The card Credential issued shows the seal drawn from the id, the full credential id and Open the verification page, then three groups:

GroupContents
Give to the holderDownload credential package (credential-<8 hex>.json), with "Keep this file private: it opens the document." and Show a QR code for the holder's phone (a compact code that adds the credential to the phone wallet; it carries the signed facts, optionally the name and ID, never the document)
Verification linkA QR code of https://<site>/verify?id=<credential id>. The link carries only the id.
Other formatsDownload as verifiable credential, Download as Open Badge, and Download as Open Badge with standard proof (three more signatures the first time)

Issue another opens a fresh form. The credential package exists only on this page; the chain cannot rebuild its salts or key, so the issuer must download it before leaving.

Wallet prompts in order​

For one credential with a document, delivery ticked, in a fresh session:

#WhenKindWhat
1Check link (optional)SignatureStudentSalt typed data. Cached for the session, so step 5 reuses it.
2, 3Encrypt and uploadSignature × 2EncryptionKey typed data, same message twice. Once per session.
4Upload, if not signed inSignatureSign-in message for the upload session
5Issue RecordSignatureStudentSalt, if not cached and delivery is ticked
6Issue RecordSignatureCredential typed data
7Issue RecordTransactionissue or issueReplacing
8After confirmationSignatureSelective disclosure attestation (optional)
9Two-step or cross-set re-issueTransactionsupersede or supersedeToRegistry on the old credential

What goes where​

On chain (public)To the serverStays in the browser or the package
Anchor: issuer, subject commitment, document hash, metadata hash, record type, issuedAt, validUntilThe encrypted container, the claimed document hash, the sign-in sessionThe document, the document key
CredentialIssued and CredentialSigned events (signature and nonce)Pin metadata: document hash and issuer addressName, student ID, programme, award
Status Active, or Superseded for a replaced credentialSubject and metadata salts, the package

Re-issue with supersede​

A row's Re-issue link opens /records/add?replaces=<id>. Delivery is pre-ticked. useReplacement (src/app/records/add/use-replacement.ts) locates the old credential in any listed contract set and decides how it can be replaced:

ModePanel textTransactions
Atomic"The old credential is marked as superseded in the same transaction that issues the new one, so you confirm once."One: issueReplacing
Two-step"When you issue the new one, the old credential is marked as superseded and points to the new one. Your wallet asks for one more confirmation for that."issue, then StatusRegistry.supersede
Cross-set"The old credential is in an older contract set. When you issue the new one, your wallet asks for one more confirmation to mark the old credential as superseded there, pointing to the new one."issue in the active set, then supersedeToRegistry in the old set, naming the active registry

Whether a set supports atomic replace is read from the contract: hasRevisions(contractVersion()) is true for labels 0.4.0 and later (src/lib/contract-revisions.ts). As of 2026-10-24 the active Sepolia set is 0.3.0, so a re-issue there takes two steps.

Blocked cases show: "This credential is already revoked, so it cannot be replaced.", "This credential was already replaced by another credential.", "An administrator holds this credential, so it cannot be replaced.", "This credential is managed by 0x.... Switch your wallet to that account to replace it.", "This credential belongs to an older contract set that cannot point to a replacement. Issue the new credential, then revoke this one from its verify page.", "The credential to replace is not a valid credential id.", "Could not check the credential to replace, so nothing was issued. Reload the page to try again."

Pending replacements​

Before the second transaction of a two-step or cross-set replacement, the page records { issuer, oldId, newId } in browser storage (certichain:pending-replacements:v1, at most 50 entries). If the second transaction fails, the panel says the old credential "could not be marked as superseded: ... Until then both show as active." with Try again. If the page is closed, the dashboard shows Replacement not finished. with Finish replacement and Dismiss. The dashboard first rechecks: an admin hold ("The old credential is on hold by an administrator, so it cannot be replaced until the hold is lifted."), a missing old credential, or a replacement that is no longer active stops it.

The contract rules for supersede are in Lifecycle.

Variants​

v0.2 (legacy)​

With NEXT_PUBLIC_CONTRACT_VERSION not v03, the form has three parts. Part 1 asks for Student Ethereum Address, part 3 uploads the plain file with Upload to IPFS (the server checks the type and hash and pins it unencrypted, 4 MB limit), and Issue Record calls AcademicRecords.addRecord with metadataHash = keccak256(cid). There is no package, no delivery choice and no re-issue; the page returns to /dashboard?success=true. See Legacy v0.2 contract.

Contract-wallet issuers​

The registries accept ERC-1271 signatures, so a contract wallet can be registered and its credentials verify. The web app, however, refuses to derive document keys or student salts from a contract wallet, so such an issuer cannot issue through this form today. The onboarding handbook says the same.

Older contract sets​

New credentials are always issued in the active set. Older sets stay readable and their credentials are re-issued across sets as above. See Contract sets routing.

Limits​

  • One credential per form submission and one wallet prompt sequence per credential. Cohorts use Bulk import.
  • validUntil is always 0; there is no expiry in the UI, although the contracts support it.
  • issuedAt is the signer's clock and may be up to 5 minutes ahead of the chain.
  • The package cannot be rebuilt. If it is lost, re-issue.

Code map​

ConcernFile and function
Page and formsrc/app/records/add/AddRecordPage.tsx, IssueSession.tsx, FormPart.tsx, IssuePreview.tsx
Issuer check and typesuse-issuer-setup.ts, issue-types.ts
Upload fieldDocumentUploadField.tsx
Link check and deliveryStudentLinkCheck.tsx, DeliveryOption.tsx, UndeliveredDialog.tsx
Submitsubmit-record.ts (submitRecord, withAttestation)
Credentialsrc/services/domain/credential-service.ts (issue)
Documentsrc/services/domain/document-service.ts (upload), src/services/document-api.ts
Replacementreplacement.ts, use-replacement.ts, ReplacementPanel.tsx, src/lib/pending-replacements.ts, src/app/dashboard/PendingReplacements.tsx
Issued cardCredentialIssuedCard.tsx, SendToPhone.tsx
Messagessrc/lib/credential-errors.ts, src/services/documents/document-error-messages.ts
Contractsblockchain/contracts/v03/CredentialRegistry.sol, StatusRegistry.sol

Tests​

  • src/app/records/add/submit-record.test.ts, replacement.test.ts, use-replacement.test.tsx, ReplacementPanel.test.tsx, StudentLinkCheck.test.tsx, DeliveryOption.test.tsx, DocumentUploadField.test.tsx, CredentialIssuedCard.test.tsx, SendToPhone.test.tsx, issue-types.test.ts
  • src/services/domain/credential-service.test.ts, src/services/domain/document-service.test.ts, src/lib/pending-replacements.test.ts, src/lib/credential-errors.test.ts
  • blockchain/test/v03/CredentialIssuance.test.ts, Revisions.test.ts, CredentialRecordTypes.test.ts
  • E2E: e2e/journeys/issue-and-verify.journey.ts, delivery.journey.ts, encrypted-documents.journey.ts, dashboard-actions.journey.ts, contract-sets.journey.ts, pause-and-restart.journey.ts