I built a credential certification platform using Soulbound Tokens on Polygon. Not a tutorial project — a production system with thousands of certificates issued. Here's the full technical breakdown.
The Problem
A client needed to issue professional certifications that:
- Could be verified by anyone, instantly, without calling the issuer
- Could never be falsified or duplicated
- Would persist even if the issuing company shut down
- Would belong to the recipient, not to a centralized database PDFs fail all four criteria. A centralized database fails the last two. Blockchain solves all four.
Why Soulbound Tokens
Regular NFTs (ERC-721) can be transferred. That's the whole point of NFTs — ownership and transferability. But for credentials, transferability is a bug, not a feature. You don't want someone selling their medical license on OpenSea.
Soulbound Tokens are non-transferable NFTs. Once minted to a wallet, they stay there forever. The concept was proposed by Vitalik Buterin in the "Decentralized Society" paper.
Why Polygon
Gas costs. Deploying on Ethereum mainnet: $50-500 depending on congestion. Minting each certificate on Ethereum: $5-50. For a system that issues hundreds of certificates, that's unsustainable.
Polygon: deploy cost ~$0.01. Mint cost ~$0.001. Same Solidity code. Same EVM. Same security model (anchored to Ethereum).
For a deeper comparison, I wrote a full analysis of Polygon vs Ethereum for business use cases.
The Smart Contract
Here's the core of the SBT contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract SoulboundCertificate is ERC721, ERC721URIStorage, Ownable {
uint256 private _nextTokenId;
constructor() ERC721("SoulboundCertificate", "SBC") Ownable(msg.sender) {}
function mint(address to, string memory uri) public onlyOwner returns (uint256) {
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
_setTokenURI(tokenId, uri);
return tokenId;
}
// Block ALL transfers — this is what makes it "soulbound"
function _update(
address to,
uint256 tokenId,
address auth
) internal override returns (address) {
address from = _ownerOf(tokenId);
// Allow minting (from == address(0)) and burning (to == address(0))
// Block all transfers between non-zero addresses
if (from != address(0) && to != address(0)) {
revert("SBT: transfer not allowed");
}
return super._update(to, tokenId, auth);
}
// Required overrides
function tokenURI(uint256 tokenId)
public view override(ERC721, ERC721URIStorage) returns (string memory)
{
return super.tokenURI(tokenId);
}
function supportsInterface(bytes4 interfaceId)
public view override(ERC721, ERC721URIStorage) returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
Key decisions:
- OpenZeppelin base: never roll your own ERC-721. OpenZeppelin is battle-tested and audited.
-
_updateoverride: in OZ v5, this is the single function that controls all token movements. Override it once, block transfers everywhere. -
onlyOwnerfor minting: only the issuing organization can create certificates. No public minting. ## Metadata on IPFS
Certificate data (name, course, date, score) is stored as JSON on IPFS, not on-chain. On-chain storage is expensive and unnecessary for metadata.
{
"name": "Advanced Solidity Development",
"description": "Professional certification issued by [Organization]",
"image": "ipfs://QmXxx.../certificate-image.png",
"attributes": [
{ "trait_type": "Recipient", "value": "Mario Rossi" },
{ "trait_type": "Course", "value": "Advanced Solidity" },
{ "trait_type": "Date", "value": "2026-01-15" },
{ "trait_type": "Score", "value": "92/100" }
]
}
The tokenURI points to the IPFS hash. IPFS is content-addressed — the hash IS the content. If anyone changes a single byte, the hash changes. Tamper-proof by design.
The Frontend
Built with Next.js + Ethers.js. Two interfaces:
Admin Panel (authenticated, for the issuer):
- Form: recipient wallet address, course details, date
- Generates JSON metadata → uploads to IPFS → calls
mint() - Dashboard of all issued certificates Public Verification (open to anyone):
- Enter a wallet address → see all SBTs held by that address
- Each certificate shows the full metadata pulled from IPFS
- Link to the transaction on Polygonscan for on-chain proof
// Simplified verification logic
const provider = new ethers.JsonRpcProvider(POLYGON_RPC);
const contract = new ethers.Contract(SBT_ADDRESS, ABI, provider);
const balance = await contract.balanceOf(walletAddress);
const certificates = [];
for (let i = 0; i < balance; i++) {
const tokenId = await contract.tokenOfOwnerByIndex(walletAddress, i);
const uri = await contract.tokenURI(tokenId);
const metadata = await fetch(uri.replace('ipfs://', IPFS_GATEWAY));
certificates.push(await metadata.json());
}
Testing
Full test suite with Hardhat:
describe("SoulboundCertificate", () => {
it("should mint to recipient", async () => {
const tx = await sbt.mint(recipient.address, "ipfs://test");
expect(await sbt.ownerOf(0)).to.equal(recipient.address);
});
it("should block transfers", async () => {
await sbt.mint(recipient.address, "ipfs://test");
await expect(
sbt.connect(recipient).transferFrom(recipient.address, other.address, 0)
).to.be.revertedWith("SBT: transfer not allowed");
});
it("should allow burning by owner", async () => {
// Revocation mechanism — issuer can burn if needed
});
});
Plus Slither for static analysis and manual review for logic bugs. I wrote about my full smart contract audit process separately.
Results
- Deployed on Polygon mainnet
- Thousands of certificates issued
- Verification time: ~3 seconds (vs days for traditional verification)
- Cost per certificate: < $0.01
- Zero downtime since launch ## When to Use This Pattern
SBTs make sense for: professional certifications, academic credentials, membership badges, compliance attestations, and any credential that should be permanent, non-transferable, and publicly verifiable.
They don't make sense for: temporary access tokens, transferable assets, or anything where privacy is critical (blockchain is public — consider ZK proofs if privacy matters).
I'm a blockchain developer building smart contracts, wallets, and DApps for businesses. If you're working on something similar, let's talk.
Top comments (0)