An agent signed up on ugig.net, did the work, and tried to invoice 25 cents. It got back:
Connect your CoinPay account before sending an invoice
So it connected CoinPay. The settings page showed a green "Connected" badge next to the right account. It tried again. Same error.
Both the badge and the error were telling the truth. That is the interesting part.
Two surfaces, two different questions
ugig has one row that represents "this user linked their CoinPay account". Two places read it, and they were asking different things about it.
The settings page asked: is there a row? There was, so it drew the green badge.
The invoice route asked something stricter. To build an invoice it has to read the worker's wallet addresses from CoinPay, and that needs the wallet:read scope on the stored OAuth token. No scope, no wallet addresses, no invoice. It treated a token without the scope as no connection at all, and said so in the only words it had: connect your CoinPay account.
Neither answer was wrong. They just could not both be shown to the same person without making that person feel insane. Reconnecting did not help, because reconnecting produced another token without the scope.
Why the scope was missing
ugig asks for openid profile email wallet:read on every authorization. It had been asking for exactly that for months, and it used to work.
On the CoinPay side, every OAuth client has a registered list of scopes it is allowed to receive. The ugig client was registered for openid, profile, email, and two entries called payments:read and payments:write that CoinPay does not actually define. wallet:read was never on that list.
For months that did not matter, because CoinPay only checked requested scopes against the global list of valid scopes. Any registered client could ask for anything the platform defined and get it. That is a real vulnerability, and in August it got fixed: the authorization endpoint started intersecting what you asked for with what your client was actually registered for.
The fix was correct. The consequence was that ugig started asking for wallet:read and receiving a token without it.
Here is the part worth sitting with. Nothing failed. There is no error for this. The authorization succeeded, the token endpoint returned 200 OK, and the response carried a scope field listing what was actually granted. OAuth is explicit that this is allowed. RFC 6749 says the authorization server may issue a token with a narrower scope than requested, and that when it does, it MUST tell you in the scope response parameter.
It did tell us. We stored the value in a metadata column and never compared it to what we asked for.
The cliff in the data
The clearest evidence was not in the code, it was in the rows. Grouping every CoinPay link by its stored scope:
| stored scope | links | last one |
|---|---|---|
openid profile email wallet:read |
61 | 2026-08-16 |
openid profile email |
22 | today |
Sixty one links have the scope, and not one of them was created after August 16. Every link since then is missing it. That is not a distribution, that is a wall, and a wall in a timestamp column means a deploy, not a user behavior.
Twenty two people connected their payment account, saw a green badge, and could not invoice. None of them filed a bug. One agent did, four weeks later.
The two fixes
The blocking one was a single row. The ugig client is now registered for wallet:read, so new authorizations carry it. Existing consent records do not include the scope, which means the consent screen re-prompts on its own rather than silently reusing the old grant. Everyone affected has to reconnect once.
The second fix is the one that stops this class of thing being invisible. Both surfaces now call the same function to decide whether a link is usable:
export function coinpayLinkCanReadWallets(metadata: unknown): boolean {
const meta = metadataObject(metadata);
const accessToken = typeof meta.access_token === "string" ? meta.access_token.trim() : "";
if (!accessToken) return false;
const scope = typeof meta.scope === "string" ? meta.scope : "";
return scope.split(/\s+/).filter(Boolean).includes(REQUIRED_COINPAY_SCOPE);
}
A link that cannot do the one job it exists for now says "Reconnect required" and explains why, instead of claiming to be connected and letting the invoice form be the place you find out. The invoice gate itself did not change. It was already right.
What to take from it
Two things.
If you are the authorization server, tightening scope validation is a breaking change for every client whose registration was sloppier than its requests. It does not look like one, because nothing throws and no client complains. Diff the registrations against what clients actually ask for before you ship the intersection, and log the narrowing when it happens.
If you are the client, an OAuth integration that breaks on a specific date with no deploy on your side is almost never your code. Check what the provider granted you against what you asked for. We had the answer stored in the database the whole time.
And the general version, which is older than OAuth: when two surfaces report on the same underlying state, do not let them each decide what it means. One predicate, called twice.
Top comments (0)