at://bnewbold.net/com.whtwnd.blog.entry/3mdc7fpbxhk26
Back to Collection
Record JSON
{
"$type": "com.whtwnd.blog.entry",
"content": "This is a hastily-written guide to creating a `did:web` atproto account.\n\nYou'll need:\n\n- familiarity with command line tools\n- a domain name and web server you control, for the did:web\n- a handle domain name you control (can be the same as the did:web domain)\n- an invite code to an atproto PDS\n- the [`goat`](https://github.com/bluesky-social/goat) tool installed (recent version)\n\nNOTE: Until recently, `goat` had a bug with deactivated accounts. This is fixed on current \"main\" branch, and should be included in v0.2.2+.\n\n## Prepare Identity (`did:web` and handle)\n\nNote that you'll be updating the DID document multiple times through this process, so you might want to keep a terminal window open.\n\nChose a domain (and thus DID identifier): `did:web:did.example.com`\n\nSeparately chose a handle: `@handle.example.com`\n\nNote that the handle does *not* need to match the `did:web` domain name! This can cause confusion. The `did:web` domain is persistent (permanent for the lifetime of this account), but the handle can be updated.\n\nChoose PDS instance: `https://pds.example.com`\n\nConfigure a DNS record for the handle (DNS can take a while to propagate, so good to start with this):\n\n _atproto.handle.example.com. TXT did=did:web:did.example.com\n\nMore details on setting up DNS-based handles [in the Bluesky app docs](https://blueskyweb.zendesk.com/hc/en-us/articles/19001802873101-How-to-Set-your-Domain-as-your-Handle).\n\nCreate a temporary atproto signing key for this DID:\n\n```shell\n$ goat key generate\n\nKey Type: P-256 / secp256r1 / ES256 private key\nSecret Key (Multibase Syntax): save this securely (eg, add to password manager)\n\tz42tuKwWH56QXkEZ8JpfkSCkTXqbQSP7ZfrThywZ3o9qaZSW\nPublic Key (DID Key Syntax): share or publish this (eg, in DID document)\n\tdid:key:zDnaeu6uTxiWcXxiQLftFPsiegCQEp7FimQ1ay33GZLSb9H55\n```\n\nYou'll need the public key in \"Multibase\" format. You can either manually remove the `did:key:` prefix, or use `goat key inspect \u003cPUBKEY\u003e`. Be careful to not confuse the secret and public parts of the keypair!\n\n\nCreate a DID document starting with this example:\n\n```json\n{\n \"@context\": [\n \"https://www.w3.org/ns/did/v1\",\n \"https://w3id.org/security/multikey/v1\",\n \"https://w3id.org/security/suites/secp256k1-2019/v1\"\n ],\n \"id\": \"did:web:did.example.com\",\n \"alsoKnownAs\": [\n \"at://handle.example.com\"\n ],\n \"verificationMethod\": [\n {\n \"id\": \"did:web:did.example.com#atproto\",\n \"type\": \"Multikey\",\n \"controller\": \"did:web:did.example.com\",\n \"publicKeyMultibase\": \"zDnaedatVBJg5bEeXsx8uA9m2ord1UKphH98E4jvb7fJm1Qfb\"\n }\n ],\n \"service\": [\n {\n \"id\": \"#atproto_pds\",\n \"type\": \"AtprotoPersonalDataServer\",\n \"serviceEndpoint\": \"https://pds.example.com\"\n }\n ]\n}\n```\n\nSome key things to note when editing the template:\n\n- the top-level `id` and all other instances of `id` and `controller` get updated to the actual `did:web` value (eg, the string 'example.com' should not appear in the final document)\n- the PDS hostname gets updated in `service`, `serviceEndpoint`\n- the handle gets set in `alsoKnownAs`, with an `at://` prefix\n- the temporary atproto public key is set under `verificationMethod`. You take the \"Public Key\" value generated by `goat` above, and remove the `did:key:` prefix\n- needs to be strictly valid JSON: no extra commas, etc\n\nThis needs to end up at `https://did.example.com/.well-known/did.json`, with an appropriate `Content-Type` header (eg, `application/json`). There are a couple ways to achieve this depending on your webserver setup. If the entire website is simple static hosting, you be able to create a directory `.well-known/` and put a `did.json` file in that folder, and the webserver will set the correct content type. Or you might need to create a mapping for just the `/.well-known/` path prefix to use static file hosting; or you might configure a fixed response body in the web server itself. Search around for how to setup a \"well-known\" file for the web server software you run (eg, nginx, haproxy, Apache, Caddy, etc).\n\nSome browser-based tools may require CORS settings to function, but this is not strictly required by the protocol specifications. If you want to enable this, it will usually be in your web server configuration, and you can search around for instructions specific to your setup.\n\nAfter waiting for DNS to propagate, confirm that things are working:\n\n```\n# starting from the DID\ngoat resolve did:web:did.example.com\n\n# starting from the handle\ngoat resolve handle.example.com\n```\n\n## Generate PDS Invite Code\n\nIf you administer your own PDS instance, you can generate a new invite code by shelling in to the PDS server (with `ssh`), and running:\n\n```\ndocker exec pds goat pds admin create-invites\n```\n\nThat will return a single code with a single use.\n\n## Create PDS Account\n\nGather your configuration into the following environment variables:\n\n- `ACCOUNT_DID`: your did:web\n- `ACCOUNT_HANDLE`: account handle that you configured\n- `TEMP_ATPROTO_SECRET_KEY`: the \"Secret Key\" part of the temporary keypair you created above\n- `PDS_HOSTNAME`: hostname of the PDS instance, without `https://` prefix\n- `INVITE_CODE`: invite to new PDS instance, for creating account\n- `ACCOUNT_PASSWORD`: secure password for the PDS account\n- `ACCOUNT_EMAIL`: account email for PDS account\n\nTo create a PDS account with an existing DID, you need to prove control of that DID as part of the account signup process. You do this using a signed auth token, which you can generate using `goat`:\n\n```\ngoat account service-auth-offline \\\n --atproto-signing-key \"$TEMP_ATPROTO_SECRET_KEY\" \\\n --lxm com.atproto.server.createAccount \\\n --iss \"$ACCOUNT_DID\" \\\n --aud \"did:web:$PDS_HOSTNAME\" \\\n --duration-sec 3600\n```\n\nSave that value as `SIGNED_TOKEN`.\n\nNow you can create the actual account on the PDS:\n\n```\ngoat account create \\\n --pds-host \"https://$PDS_HOSTNAME\" \\\n --existing-did \"$ACCOUNT_DID\" \\\n --handle \"$ACCOUNT_HANDLE\" \\\n --password \"$ACCOUNT_PASSWORD\" \\\n --email \"$ACCOUNT_EMAIL\" \\\n --invite-code \"$INVITE_CODE\" \\\n --service-auth \"$SIGNED_TOKEN\"\n```\n\nAssuming that worked, the account will be in \"deactivated\" state, because the PDS can not make signatures on behalf of the account. You will need to update the DID document with the PDS-controlled signing key before the account can be used.\n\nYou should be able to log in using `goat`:\n\n```\ngoat account login \\\n --pds-host \"https://$PDS_HOSTNAME\" \\\n -u \"$ACCOUNT_DID\" \\\n -p \"$ACCOUNT_PASSWORD\"\n```\n\nNOTE: if you get an error here about inactive state, you may need to upgrade `goat`. If you only get a warning log line, you can ignore it.\n\nFetch the recommended DID parameters from the PDS (this requires an authenticated login). Note that the command and response mention \"plc\", but this API call is not actually did:plc-specific.\n\n```\n$ goat account plc recommended\n\n{\n \"alsoKnownAs\": [\n \"at://handle.example.com\"\n ],\n \"verificationMethods\": {\n \"atproto\": \"did:key:zQ3shr9kTLFhFCxFpmwSZ8bLb7gfekfdownKYufYBUp1iFcvp\"\n },\n \"rotationKeys\": [\n \"did:key:zQ3shcciz4AvrLyDnUdZLpQys3kyCsesojRNzJAieyDStGxGo\"\n ],\n \"services\": {\n \"atproto_pds\": {\n \"type\": \"AtprotoPersonalDataServer\",\n \"endpoint\": \"https://pds.example.com\"\n }\n }\n}\n```\n\nTake the `verificationMethods`, `atproto` value, remove the `did:key:` prefix, and insert it in to the did:web document:\n\n```json\n \"verificationMethod\": [\n { \n \"id\": \"did:web:did.example.com#atproto\",\n \"type\": \"Multikey\",\n \"controller\": \"did:web:did.example.com\",\n \"publicKeyMultibase\": \"zQ3shr9kTLFhFCxFpmwSZ8bLb7gfekfdownKYufYBUp1iFcvp\"\n }\n ]\n```\n\nConfirm that your update worked by re-resolving the DID:\n\n```\ngoat resolve did:web:did.example.com | jq .verificationMethod\n```\n\nYou should now be able to activate the PDS account:\n\n```\ngoat account activate\n```\n\nThe account should now be functional in the network!\n\nYou can confirm account status on a popular relay instance:\n\n```\ngoat relay account status --relay-host https://relay1.us-east.bsky.network did:web:did.example.com\n```\n\nCreate a microblogging post and check if it shows up in apps:\n\n```\ngoat bsky post \"first test post from did:web account\"\n\n# check apps:\n# https://bsky.app/profile/did:web:did.example.com\n# https://staging.blacksky.community/profile/did:web:did.example.com\n```\n\nIt is possible that some servers or services will have stale identity metadata cached for the account at this point. This can cause \"invalid handle\" errors, or break inter-service auth (eg, feed generation requests). Sometimes these will resolve after 24 hours. A better way to resolve this would be self-service PDS functionality to emit additional `#identity` events for the account, which would usually result in all downstream services in the network to reload their identity metadata within a few seconds.\n",
"createdAt": "2026-01-26T02:35:17.332Z",
"theme": "github-light",
"title": "Creating a did:web atproto account using goat",
"visibility": "public"
}