Identity
Verifying Your X Account

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.

ParameterValue
Authorize endpointhttps://x.com/i/oauth2/authorize
response_typecode
client_idX_OAUTH_CLIENT_ID from config
redirect_uriThe current origin plus /profile/x/callback
scopeusers.read tweet.read
statesuperhero_x_ followed by 16 random bytes, base64url
code_challengebase64url of SHA-256 over the verifier
code_challenge_methodS256

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;
  • state starts with superhero_x_;
  • the stored PKCE record exists and its state matches 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:

FieldMeaning
messageThe exact string the wallet must sign: link:<address>:x:<handle>:<nonce>
nonceAddressLink.get_nonce(address), the contract's own per-address counter
valueThe X username the backend resolved, trimmed and lowercased
verification_tokenA 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.

FailureResponse
Token missing, malformed, or its HMAC does not verify400
Date.now() > payload.expiry400 "Verification token has expired"; restart from claim
Token address, provider or value does not match the body400, one message per field
Signature is not 128 hex characters400 before the call is built
Contract rejects the signature400, from INVALID_SIGNATURE
The nonce moved since the claim400, from INVALID_NONCE; restart from claim
The handle is already linked to a different value for this address400, 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