Smart contracts that bind a GitHub account to a wallet address, with no repository of your own to modify.
At the foundation is the User Identity Key (UIK), a soulbound ERC-721 whose token id is the GitHub account's numeric id. A UIK can only be minted from a GitHub Actions OIDC proof, creating a public, cryptographically verifiable link between a GitHub account and a wallet.
Where a repository identity proves who owns this code, a user identity proves who this developer is. It is the primitive underneath contributor reputation, payouts to people rather than projects, and any protocol action that should be attributable to a human.
This repository takes bug reports and security reports only.
Security vulnerabilities must be reported privately — never through an issue or a pull request. SECURITY.md has the contact address and the scope. CONTRIBUTING.md covers everything else, including why a bug report's title must not begin with 0x.
One click. No repository to fork, no file to commit, no app to install, no scope to grant, no gas to pay.
- Open an issue on this repository whose title is your wallet address and nothing else.
- The attestation workflow proves your GitHub identity and submits the proof.
- The workflow comments the transaction hash and closes the issue.
Opening another issue with a different address rebinds the identity to the new wallet. That is the recovery path for a rotated or lost key, and the reason a mistaken registration is never permanent.
A GitHub Actions OIDC token is signed by GitHub and cannot be forged. But GitHub will sign actor_id: alice next to whatever aud the requesting workflow asks for, so the entire security of the scheme reduces to one question:
who controlled the workflow code that requested the token?
The issues event is the key. It runs in this repository's context while setting actor_id to the external account that opened the issue. That is what removes every requirement from the person registering. It is also, in the wrong hands, an impersonation primitive — so UIK pins the answer to the question above on-chain:
| Claim | Pinned to | Without it |
|---|---|---|
repository_id |
this repository | anyone could invoke the workflow as a reusable workflow from their own repo and choose the audience |
job_workflow_ref |
.github/workflows/register.yml at its ref |
rewriting the workflow would be enough to mint anyone's identity to any address |
event_name |
issues |
widens the trigger surface beyond what was analysed |
actor_id |
the account being registered | no identity is proven at all |
aud |
the wallet being bound | the proof could be redirected to another address |
The address comes from the issue title, which is publicly and permanently attributable to the account that opened it. No backend chooses it, and none can substitute it. Changing the pinned workflow requires an owner transaction against the deployed contract, so the code that mints identities cannot be swapped silently.
Because the proof names its own beneficiary through aud, register is permissionless. The attestation workflow submits the transaction itself and pays the gas, using a key that holds no authority over the registry: drain it and you have cost someone gas, never an identity. Anyone else holding the same proof could broadcast it to exactly the same effect.
Metadata is served fully on-chain as a base64 data: URI, so what a wallet displays rests on the same guarantees as the binding itself rather than on a server that could say anything.
{
"name": "@octocat",
"description": "User Identity Key. Proves that GitHub account 583231 controls this wallet, through a GitHub Actions OIDC attestation.",
"image": "https://avatars.githubusercontent.com/u/583231",
"external_url": "https://github.com/octocat",
"attributes": [
{ "trait_type": "GitHub User ID", "value": "583231" },
{ "trait_type": "Login At Binding", "value": "octocat" },
{ "trait_type": "Bound Wallet", "value": "0x1111111111111111111111111111111111111111" },
{ "display_type": "date", "trait_type": "Bound At", "value": 1754000000 }
]
}The image needs no stored value: GitHub serves avatars by account id, which is the token id. The login is recorded only for the name and the profile link, because GitHub has no id-addressable profile page — it is proven against the signed actor claim, refreshed on every rebinding, and never used as an identifier.
Two extensions matter here:
- ERC-4906. Rebinding changes the holder, the timestamp and possibly the login, so the contract emits
MetadataUpdateto stop marketplaces serving a stale render forever. - ERC-5192. The token reports
locked() == true, so clients hide transfer controls instead of offering an action that always reverts.
Rendering is delegated to a swappable ITokenRenderer, falling back to the built-in renderer whenever none is set or a renderer misbehaves. That authority is display-only — it can never mint, move, or unbind an identity — and it can be given up permanently with freezeRenderer(), which deliberately leaves the attestation source rotatable.
src/UIK.sol: soulbound identity ERC-721. Token id is the GitHub account id.src/GithubOidcVerifier.sol: mirrors GitHub's JWKS and verifies the RSA signature, issuer, and active window.src/IJwtVerifier.sol: the verifier boundaryUIKconsumes.src/ITokenRenderer.sol: the swappable metadata renderer boundary.src/IERC5192.sol: minimal soulbound interface.src/JsonClaim.sol: byte-oriented JSON claim matcher.
git submodule update --init --recursive
forge fmt --check
forge build --sizes
forge test -vvvTests sign real RSA JWTs through test/fixtures/load-fixture.mjs under vm.ffi, so Node is required. Every negative case is a JSON fixture rather than a hand-crafted token.
