Verifying Your X Account
Linking an X handle to your æternity address is an OAuth session the Superhero backend
observes, turned into an on-chain AddressLink.link call that the backend submits and pays
for. The flow has a browser leg, two REST calls and one wallet signature, and the same
claim-sign-submit shape is used by every other address-link provider.
The flow end to end
browser superhero backend æternity chain
| | |
| 1. build PKCE + state | |
| 2. redirect to x.com/i/oauth2/authorize |
|<-- 3. redirect back with code + state -- |
| 4. POST /api/address-links/x/claim (code, verifier, uri) |
| | exchanges code for a token |
| | reads the X username |
| | reads AddressLink.get_nonce |
|<-- 5. claim: message, nonce, value, verification_token -- |
| 6. wallet signs `message` | |
| 7. POST /api/address-links/x/submit (value, nonce, sig, token)
| | verifies the HMAC token |
| | 8. AddressLink.link(...) -------->
|<-- 9. txHash ----------------| |Two secrets are involved:
- The user's æternity signature over the claim
message. This goes on chain, and it is the only cryptographic material the contract checks. - The verification token, an HMAC-SHA256 tag the backend issues at claim time and verifies at submit time. It never goes on chain. It ensures a caller can only submit a handle the backend observed an OAuth session for.
Step 1: the OAuth leg (PKCE)
The client runs a standard OAuth 2.0 authorization-code flow with PKCE.
| Parameter | Value |
|---|---|
| Authorize endpoint | https://x.com/i/oauth2/authorize |
response_type | code |
client_id | X_OAUTH_CLIENT_ID from config |
redirect_uri | The current origin plus /profile/x/callback |
scope | users.read tweet.read |
state | superhero_x_ followed by 16 random bytes, base64url |
code_challenge | base64url of SHA-256 over the verifier |
code_challenge_method | S256 |
The verifier is 32 random bytes, base64url-encoded. The scopes are exactly what the backend
needs to call X API v2's /2/users/me, and nothing that can post on your behalf.
The entry point is gated on X_OAUTH_CLIENT_ID (set from VITE_X_OAUTH_CLIENT_ID at build
time). When that value is empty the Connect X control is hidden.
Before redirecting, the client stores state, codeVerifier, address and redirectUri
under the key superhero_x_oauth_pkce in both sessionStorage and localStorage, with a
createdAt timestamp and a 15-minute time-to-live. A read falls back from session to local,
and both are cleared on read.
Keep the app and the callback on the same host. Switching between localhost and 127.0.0.1
mid-flow loses the wallet session along with the PKCE record.
Step 2: the callback
/profile/x/callback reads code and state from the query string and proceeds only when:
- both are present;
statestarts withsuperhero_x_;- the stored PKCE record exists and its
statematches exactly.
The stored record is read and cleared in one operation, so the callback runs exactly once per page load. The callback then restores the address as a signer and calls the claim endpoint.
Step 3: claim
POST /api/address-links/x/claim
Content-Type: application/json
{ "address": "ak_…", "x_code": "…", "x_code_verifier": "…", "x_redirect_uri": "https://…/profile/x/callback" }The backend exchanges the code for an access token itself, so the token never touches the browser. A second body shape is accepted for callers that already hold a token:
{ "address": "ak_…", "x_access_token": "…" }The response is a claim:
| Field | Meaning |
|---|---|
message | The exact string the wallet must sign: link:<address>:x:<handle>:<nonce> |
nonce | AddressLink.get_nonce(address), the contract's own per-address counter |
value | The X username the backend resolved, trimmed and lowercased |
verification_token | A base64url HMAC token binding address, provider, value and expiry |
The username is taken from the X profile as username || name, then .trim().toLowerCase().
A profile the backend cannot extract a username from is a 400.
The verification token
payload = { address, provider: "x", value, expiry }
token = base64url( JSON.stringify(payload) + "." + HMAC_SHA256(payload, ADDRESS_LINK_SECRET_KEY) )expiry is Date.now() + ADDRESS_LINK_VERIFICATION_TTL_SECONDS * 1000, which is five minutes by
default. The token is not encrypted, only unforgeable. Verification recomputes the HMAC and
compares it in constant time. The HMAC key is ADDRESS_LINK_SECRET_KEY, the same value used as
the provider wallet's private key.
Step 4: the user's signature
Sign claim.message verbatim with signMessage. Do not reconstruct it locally and do not
pre-hash it: signMessage applies the æternity personal-message framing that the contract
reproduces internally, and any deviation fails as INVALID_SIGNATURE.
The client verifies the result locally with the SDK's verifyMessageSignature before sending
it, and normalizes it to lowercase hex: an sg_… signature is decoded to bytes and
hex-encoded, a 0x prefix is stripped. The backend rejects anything that is not exactly 128
hex characters.
Trigger the signing prompt from an explicit user click. Browsers and pop-up blockers suppress a wallet window opened from an effect rather than a gesture, so the callback screen shows a confirm button and re-enables it on failure.
Step 5: submit
POST /api/address-links/x/submit
Content-Type: application/json
{ "address": "ak_…", "value": "<x username>", "nonce": 0, "signature": "<hex>", "verification_token": "…" }The submit handler checks the token (not expired, address matches, provider is x, value
matches) and then calls AddressLink.link(address, "x", value, nonce, signature) from the
provider wallet. The response is txHash. The link is now pending confirmation; the client
invalidates the profile query so the verified badge appears once the backend has indexed it.
| Failure | Response |
|---|---|
| Token missing, malformed, or its HMAC does not verify | 400 |
Date.now() > payload.expiry | 400 "Verification token has expired"; restart from claim |
| Token address, provider or value does not match the body | 400, one message per field |
| Signature is not 128 hex characters | 400 before the call is built |
| Contract rejects the signature | 400, from INVALID_SIGNATURE |
| The nonce moved since the claim | 400, from INVALID_NONCE; restart from claim |
| The handle is already linked to a different value for this address | 400, from ALREADY_CLAIMED |
Unlinking
Unlinking is symmetrical and also signed, but requires no X proof: possession of the æternity key is sufficient to remove your own link, so there is no verification token.
POST /api/address-links/x/unclaim → { "message": "unlink:ak_…:x:1", "nonce": 1 }
(wallet signs message)
POST /api/address-links/x/unclaim/submit → { "txHash": "…" }The same claim-sign-submit shape is used for bio, site, prefered-aens-name and nostr,
with only the path segment changing. To build a new provider, copy this pattern; the
per-provider work is a verifier that turns your proof into a value plus a verification token.
Contract-side verification
The contract checks one thing: that the 64-byte signature verifies against the subject address
over the personal-message digest of the message string. That check, plus the nonce and the
provider-owner rule, is the whole of it.
digest = Blake2b-256( prefix(28 bytes) || Blake2b-256(message) )
ok = Crypto.verify_sig(digest, addr, sig)The prefix string aeternity Signed Message: plus the newline is 26 bytes. The SDK's personal
message encoding writes a length byte before the prefix and a length byte before the body, so
the header is 1 + 26 + 1 = 28 bytes. The body is the 32-byte Blake2b digest of the message,
giving 28 + 32 = 60 bytes hashed a second time. The framing makes an address-link signature
unusable as a raw transaction signature.
Crypto.verify_sig(msg, pubkey, sig) is the Sophia standard-library entrypoint for Ed25519
verification. On compiler version 8 and later it accepts a message of any length; earlier
compilers restrict it to 32 bytes, which is why the scheme hashes down to a digest before
verifying.
Replay is prevented by the nonce, which is the contract's own nonce_seq[addr] and advances on
every link and unlink. A captured link:…:x:…:0 signature is unusable once any link for that
address lands.
What an X link proves
On chain: the address signed the string link:<address>:x:<handle>:<nonce>, and the
provider owner submitted it. That is a statement of consent by the address holder to being
associated with that handle.
Off chain: the backend saw a valid X OAuth session for that handle, five minutes or less before submission. That evidence stays inside the backend, sealed in the HMAC token, and is not published.
The trust anchor is therefore the provider wallet: whoever holds ADDRESS_LINK_SECRET_KEY owns
the x namespace and is the only party that can write into it. A third party reading the chain
can confirm that the address consented to the handle string, and relies on Superhero's backend
for the OAuth check. The namespace owner is readable on chain as
AddressLink.get_provider_owner("x").
Related
- AddressLink: the contract this flow writes to, its provider-ownership model, and its nonce.
- SuperheroID: the rest of what an identity links.
- Profiles & Accounts: where
x_usernamesurfaces once indexed. - Chat: the
nostrprovider, whose proof is a signed Nostr event rather than an OAuth session. - Rewards program: verifying X is one of the reward milestones.
- Sophia standard library (opens in a new tab):
the reference for
Crypto.verify_sig.