Verifire
Verification through Firehose
Verify control of an atproto account from your app.
No OAuth, no app passwords.
Issue a one-time code. The user posts it from any atproto app, or writes it into any record in their repo (a Bluesky post, a Blacksky post, a custom record). Verifire spots it on the firehose and returns the DID, handle, and record URI.
Verifire proves moment-in-time control of an account, nothing more. Best for low-to-medium-stakes flows, not a substitute for OAuth.
Reach for it when OAuth or app passwords aren't an option and you just need to know which atproto account you're talking to.
Verify a handle
Click for a one-time code, then post it on Bluesky, Blacksky, or any atproto record from your account.
Put this code in any record in your atproto repo. A short Bluesky or Blacksky post is the easiest path.
The code wasn't seen on the firehose within 300 seconds.
Good for / not for
| Good for | Not for |
|---|---|
| Event check-ins | Full login replacement |
| Bot / admin verification | Payments, sensitive auth |
| Account linking | Long-term identity guarantees |
| Demos, hackathons | Anywhere OAuth fits and you can use it |
API
Two HTTP calls: create a challenge, then poll for the result. Or pass a webhookUrl and we'll POST you when it lands.
Smallest version:
# create a challenge, get a code curl -X POST https://verifire.at/xrpc/at.verifire.createChallenge → { "challengeId": "chl-…", "code": "k3m9xq7p" } # user posts the code anywhere in their repo, then poll curl https://verifire.at/xrpc/at.verifire.getChallenge?challengeId=chl-… → { "status": "verified", "did": "did:plc:…", "handle": "alice.bsky.social" }
Create a challenge
POST /xrpc/at.verifire.createChallenge Content-Type: application/json // All fields optional. { "webhookUrl": "https://your-app.example/verifire-callback", "codeLength": 16, "ttlSeconds": 600, "requirePrefix": "verifire-", "codeAlphabet": "numeric", "expectedDid": "did:plc:abc...", "collection": "app.bsky.feed.post" }
| Field | Default | Notes |
|---|---|---|
| webhookUrl | — | Public http(s) URL we POST when verified. E.g. https://your-app.com/verifire-webhook. Body shape: { challengeId, did, handle, matchedAt, recordUri }. Fire-and-forget, no retries, no signing. |
| codeLength | 8 | Length of the random code. Range 8 – 32. Default 8 → ~42 bits entropy (e.g. k3m9xq7p); 16 for higher security (e.g. k3m9xq7pa1b2c3d4). |
| ttlSeconds | 300 | Validity window in seconds. Range 30 – 86400 (24 h). 60–120 for tight in-app flows; 600+ when the user needs time to switch apps and post. |
| requirePrefix | — | If set, the user must post prefix + code. E.g. with requirePrefix: "acme-" and code k3m9xq7p, the user posts acme-k3m9xq7p. Eliminates false-positive matches and signals intent. 1–32 chars, no control chars. |
| codeAlphabet | alphanumeric | Default alphanumeric (a-z + 0-9). Override to numeric for digit-only codes (e.g. 4853109267), easier to read over voice or type on a phone keypad. Pair with longer codeLength to compensate for lower entropy per character. |
| expectedDid | — | Restrict the challenge to a specific account. E.g. did:plc:7gm5ejhut7kia2kzglqfew5b. Only that DID's posts will match; posts from any other account are silently ignored. Useful when you already know which atproto account you're verifying (login flow, account-recovery flow, bot operator config). |
| collection | — | Restrict matching to a single collection (NSID). E.g. app.bsky.feed.post to only accept Bluesky posts, or your own record type like events.atproto.checkin. Records in any other collection are ignored. Unset (default) = the code matches in a record of any collection. |
Response (200):
{
"challengeId": "chl-…",
"code": "k3m9xq7p",
"expiresAt": "2026-04-26T03:05:00.000Z",
"ttlSeconds": 300,
"instruction": "Post the code k3m9xq7p anywhere in your atproto repo …"
}
Get a challenge's status
GET /xrpc/at.verifire.getChallenge?challengeId=chl-…
Pending:
{ "status": "pending", "expiresAt": "…" }
Verified:
{
"status": "verified",
"did": "did:plc:…",
"handle": "alice.bsky.social",
"recordUri": "at://did:plc:…/app.bsky.feed.post/3kj7yqz2",
"matchedAt": "2026-04-26T03:01:42.123Z",
"expiresAt": "2026-04-26T03:05:00.000Z"
}
Expired:
{ "status": "expired", "expiresAt": "…" }
Webhook payload
Sent once on a successful match if webhookUrl was set. No retries, no signing.
{
"challengeId": "chl-…",
"did": "did:plc:…",
"handle": "alice.bsky.social",
"matchedAt": "2026-04-26T03:01:42.123Z",
"recordUri": "at://did:plc:…/app.bsky.feed.post/3kj7yqz2"
}
Errors
XRPC shape: { "error": "Code", "message": "…" }
| HTTP | error | When |
|---|---|---|
| 400 | InvalidRequest | Bad challengeId, or option failed validation. |
| 400 | InvalidWebhookUrl | Not http(s) or resolves to a private IP. |
| 404 | ChallengeNotFound | Unknown id, or already swept. |
| 429 | — | Rate limit: 10/min create, 60/min get, per IP. |
| 503 | AtCapacity | Pending cap reached. Retry shortly. |
For integrators
Verifire reports the DID that posted your code, and when. That's a moment-in-time proof of control, nothing more. Three rules to use it safely:
- Create challenges from your server. Your backend owns the
challengeIdand ties it to the session you're verifying. Create it in the browser and that mapping is the user's to forge. - Check the returned
did. Onverified, only proceed if it's the account you expected. Or setexpectedDidup front and Verifire enforces it, ignoring everyone else. - The code is a bearer token. Whoever posts it verifies as their own DID, so the real risk isn't a stranger claiming it. It's relaying your code to the genuine owner and having them post it for you. A short
ttlSecondsplusexpectedDidclose that window.