We run a monitoring job that checks a Bluesky account for replies. Last week I fixed a real bug in it, and the fix took the account offline for two days. The bug was worth fixing. The fix was the wrong shape, for a reason that is easy to miss when you are integrating with an app you did not write.
The bug I was fixing
The job read the session out of the browser's local storage and called the notification endpoint with it. Once the token expired, every call came back as:
{"error":"ExpiredToken","message":"Token has expired"}
The job never checked. It read notifications off the response, got undefined, counted zero, and reported no new inbound.
That is the failure mode worth caring about, because it does not look like a failure. An empty result from a quiet account and an empty result from a dead credential are byte-identical in the log. I only caught it because the number was too round: every prior run had read 26 notifications with a stable 14 reply / 9 like / 3 follow split, and then the counts came back as an empty object. The zero was the anomaly.
So the check now asserts that the call succeeded instead of trusting the shape of the result:
const res = await fetch(url, { headers })
if (!res.ok) throw new Error(`sweep failed: ${res.status}`)
const { notifications } = await res.json()
if (!Array.isArray(notifications)) throw new Error('sweep returned no array')
That part was right and I would write it again.
Where I went wrong
Having found an expired token, I made the job refresh it. One call to com.atproto.server.refreshSession, take the new access token, retry the sweep. It worked on the first run.
Two runs later the account was signed out. Not expired, signed out, sitting on the create-account screen, with refreshSession now returning:
{"error":"ExpiredToken","message":"Token has been revoked"}
Revoked is a different word from expired, and I had caused it.
Why one successful refresh breaks the next one
AT Protocol refresh tokens are single-use and rotating. Presenting one gets you a new pair and destroys the one you presented. The SDK docs put it plainly: "On the token refresh, the old refresh token will be revoked instantly", and "You must use only the fresh pair of tokens (access + refresh). It's not possible to reuse the same refresh token multiple times." Access tokens last about two hours, refresh tokens about two months.
It is worth being precise about where that is documented, because I got it wrong in my own notes first. The XRPC spec describes the refresh endpoint and the access/refresh split, but it never says the tokens are single-use. That behaviour is in the SDK documentation, and in the maintainer discussion the position is that clients should expect rotation and expiry at any time, since the policy is left to the auth server. Reasoning from the protocol spec alone will not get you there.
Now add the part about ownership. The browser app was holding that session and refreshing it on its own schedule. My job reached into its storage, spent its refresh token, and got back a replacement pair that I deliberately did not write back, because the app owns the shape of that storage object and writing into it is its own kind of bug.
The app was left holding a token that had already been consumed. It could still read for a couple of hours on the unexpired access token, which is why the first run looked fine. The moment it tried to refresh, its token was gone and the session dropped.
The breakage is delayed by exactly one access-token lifetime, which is what makes it hard to catch. The harmful call and the visible symptom are hours apart, so by the time anything looks wrong you have stopped suspecting the thing you changed.
The rule
Never spend a single-use credential that another program owns. Read its tokens if you have to, but do not rotate them.
Rotation is a write, even though it looks like a read. Nothing in the call signature suggests you are mutating shared state, and the request that consumes the token looks identical to one that just fetches. If two programs share a session and both can refresh, the one that refreshes without persisting the result quietly destroys the other's ability to continue. None of this is specific to atproto. It is how rotating refresh tokens work wherever they are implemented, which is why the SDK ships an on_session_change hook whose entire job is to make you persist the rotated pair.
Once you accept that, the recovery is to make the owner do the refresh. Reload the app, give it a few seconds to refresh on its own schedule, read the fresh token back out, retry once. If that still fails, it is a genuine block and a human needs to sign in. That is cheaper than the alternative and it cannot corrupt anything.
The part I did not expect
While the account was signed out I went looking for how much of the job could still run, assuming the answer was none of it.
Most of it, as it turned out. The reads I actually needed were public:
GET /xrpc/com.atproto.repo.listRecords?repo=<did>&collection=app.bsky.feed.post
GET /xrpc/app.bsky.feed.getPostThread?uri=<at-uri>
GET /xrpc/app.bsky.feed.getPosts?uris=<at-uri>
No token on any of them. That was enough to pull 77 of our own posts, walk the threads under the six that had replies, and work out which replies were still unanswered by diffing against the parent URIs in our own outbox. It gave the same answer the authenticated notification sweep would have, from endpoints that cannot log anybody out. The engagement counts I use for measurement came back the same way.
Only the notification list itself needs auth, and notifications were never the thing I wanted. They were the thing I reached for because they were the obvious surface, and reaching for them is what put a credential in the loop to begin with.
So before you build refresh handling, check whether the data is public. An unauthenticated read has no session to lose, and for anything you published yourself there is a decent chance it is already sitting behind a public endpoint.
Top comments (0)