chris@schmid:~$
← ./blog

article

World ID SSO: On-Chain vs. Cloud, Side by Side

Next.jsBlockchainWorld IDOAuth

Most “Sign in with X” buttons trust a database somewhere to say who you are. World ID’s whole pitch is proof of unique personhood without handing over an identity — an iris scan collapses into a zero-knowledge proof, and the proof is what gets checked, not you. For a Wirtschaftsinformatik Bachelor’s thesis built during my time at Telefónica’s corporate innovation lab, I implemented that check two different ways — once on-chain, once as a conventional cloud OAuth flow — to see what actually changes when you move the trust boundary from a smart contract to a database.

The on-chain path

The blockchain implementation is a Next.js app wired up with wagmi and ConnectKit for wallet connection, plus Worldcoin’s @worldcoin/idkit widget to collect the proof. Verification happens in a small Solidity contract, not a server:

function verifyAndExecute(
    address signal,
    uint256 root,
    uint256 nullifierHash,
    uint256[8] calldata proof
) public {
    if (nullifierHashes[nullifierHash]) revert InvalidNullifier();

    worldId.verifyProof(
        root, groupId,
        abi.encodePacked(signal).hashToField(),
        nullifierHash, externalNullifier, proof
    );

    nullifierHashes[nullifierHash] = true;
}

The externalNullifier is derived once, in the constructor, from the app ID and an action ID hashed together. The nullifierHash is what actually stops the same person from signing in twice under the same action — it’s recorded in a mapping, and a repeat proof for the same nullifier reverts before it ever reaches application logic. Nobody’s identity is stored; only a hash saying “this proof has been spent.”

The cloud path

The second implementation throws that away and does it the way most SaaS products do: NextAuth, configured with Worldcoin as a plain OIDC provider against id.worldcoin.org’s well-known configuration —

{
  id: "worldcoin",
  type: "oauth",
  wellKnown: "https://id.worldcoin.org/.well-known/openid-configuration",
  authorization: { params: { scope: "openid" } },
  checks: ["state", "nonce", "pkce"],
  profile(profile) {
    return {
      id: profile.sub,
      verificationLevel: profile["https://id.worldcoin.org/v1"].verification_level,
    }
  },
}

Here the proof is verified once, upstream, by Worldcoin’s identity service — the app just receives a verification_level claim in an ID token and trusts the issuer. No wallet, no gas, no contract call. A user who’s already verified their World ID once can sign in the same way they’d sign in with Google.

What actually changes

Functionally, both paths answer the same question — “has this human proved uniqueness for this action, exactly once?” — but they disagree about where that answer has to live. The on-chain version pays for every verification in gas and needs a connected wallet before anything else can happen, and in exchange the nullifierHashes mapping is public and auditable by anyone, forever, without trusting a party to have checked it correctly. The cloud version is a login button anyone already knows how to press, with none of the wallet friction — and none of the on-chain guarantee either; you’re back to trusting an issuer and a session token.

Neither implementation is “the answer” — they’re the same identity primitive under two completely different trust models, which was exactly the point of building both instead of just reading about the trade-off.

Source: github.com/chris017/Bachelor-Project-Web3 · Live: bachelor-project-web3.vercel.app