skip to content

A meal planner stores one third-party API grant row per user and provider — why is the granted scope set part of that row's identity?

level: middleimportance: must knowfreq 55%

answer

  1. an authorization, not a connection
  2. reading a list is not writing to it
  3. a refresh may not widen what was granted
  4. key on user, provider, granted scope set
  5. encrypt the tokens, index the rest

basics

~20 s

Read access to a list is not write access to it, and a refresh request may not ask for scope that was never granted, so user plus provider plus the granted scope set identifies the authorization you actually hold.

solid answer

~40 s

The row does not record that a user connected a provider; it records one authorization that user gave, and an authorization means nothing apart from what it permits. A grant that reads a grocery list cannot write to it, and RFC 6749 §6 says a refresh request must not ask for scope that was not originally granted — so you cannot upgrade the stored row by refreshing it. Key the row on user, provider and the scope set the token response actually granted; resolve `expires_in` into an absolute expiry, keep the last successful use and a status, and encrypt only the two token columns, under a key a database dump does not contain. Code about to write a list then checks the recorded scope instead of discovering `403` with `insufficient_scope` halfway through a job.

code

json · 13 lines
json
{
  "user_id": "u_8391",
  "provider": "grocery-list-provider",
  "granted_scope": ["list.read", "list.write"],
  "access_token_ciphertext": "base64:9sQ1...",
  "refresh_token_ciphertext": "base64:Kd72...",
  "key_id": "grant-kek-2026-03",
  "access_token_expires_at": "2026-09-19T14:05:00Z",
  "refresh_due_at": "2026-09-19T13:41:00Z",
  "last_successful_use_at": "2026-09-19T13:02:11Z",
  "status": "ACTIVE",
  "status_reason": null
}

go deeper

for a junior

Recall that the service keeps a per-user, per-provider row rather than one application-wide credential, and that the row records which permissions that user actually granted.

for a middle

Explain why the granted scope set is part of the key: a narrower grant is a different authorization, and a refresh request may not ask for scope the user never granted.

for a senior

Show the operational columns — absolute expiry, due time, last successful use, status with a reason — and say which must stay in plaintext so the refresh query can still use an index.

for a principal

Weigh the blast radius: this table is standing write access to other people's accounts, so argue for a key the database dump never contains and a retention rule that clears dead grants.

## What this row actually is A service that writes to somebody else's API on a user's behalf ends up with a table nobody sat down and designed. It starts life as *somewhere to keep the tokens* and becomes the most valuable table in the schema: every live row is standing write access to another person's account at another company, usable while that person is asleep. That is why the row's **identity** deserves more thought than its columns. The row does not record *that a user connected a provider*. It records **one authorization that one user gave**, and an authorization is meaningless apart from the thing it permits. ## Why the granted scope set is part of the identity - **A grant that reads a list is not a grant that writes to it.** Two rows with the same user and the same provider can permit completely different operations, so user plus provider identifies nothing you can safely act on. - **You cannot upgrade the row by refreshing it.** RFC 6749 §6 is explicit: a refresh request may carry a `scope` parameter, but the requested scope must not include any scope that was not originally granted, and omitting it means the same scope as before. Widening is a new authorization, not an `UPDATE`. - **Record what was granted, not what you asked for.** The token response reports the scope the user actually approved, which may be narrower than your authorization request. A row that stores the wish rather than the grant makes every downstream permission check lie. - **Narrower is not a damaged version of wider.** A read-only grant is a complete, valid authorization over a smaller set of operations, and feature gating should read it that way rather than as a broken write grant. ## What else the row has to carry to be operable The two tokens alone will not run anything: 1. **An absolute expiry.** `expires_in` arrives as a number of seconds relative to the response; resolve it once against your clock and store the instant. Nothing later can reconstruct it. 2. **The last successful use.** The only evidence you hold that the grant still works, and the input to every dormancy and retention rule. 3. **A status with a reason and a timestamp.** Active, dead, needs re-authorization — with *why* and *when*, because the job that skips a row and the screen that explains the skip both read this column. 4. **The identifier of the key the token columns are encrypted under**, so that rotating that key is a row-by-row migration rather than one enormous transaction. And deliberately **not** stored: anything derivable. *Is it expired* is a comparison against the clock, not a boolean somebody must remember to flip. ## Which columns are secrets and which must stay searchable The access token and the refresh token are the only secrets in the row. Encrypt them in the application, under a key the database dump does not contain — the whole point of the exercise is that a copy of the table is not a copy of the access. | column | secret? | treatment | why | |---|---|---|---| | access token, refresh token | yes | ciphertext, key held outside the database | a dump must not be usable access | | absolute expiry | no | plaintext, indexed | every refresh sweep filters on it | | granted scope set | no | plaintext, part of the key | permission checks read it on every call | | status, last successful use | no | plaintext, indexed | selects the rows worth touching at all | | key identifier | no | plaintext | lets one key rotate row by row | Encrypting a column removes it from every index and every range predicate. Encrypt the expiry and the *which grants are due* query becomes a full-table decrypt; encrypt the scope set and every permission check turns into a key operation. Neither value is a secret, so neither is worth that price. ## What breaks when the row is keyed on user and provider alone - A feature needing a wider permission quietly uses a narrower grant and fails at the provider — `403` with `insufficient_scope`, halfway through a job, on the provider's schedule rather than yours. - A second authorization covering a different permission set overwrites the first, and background work that depended on the old one stops with nothing recording why. - A migration that introduces a new permission cannot ask *which users have already approved it*, because the answer was never stored as data. - "Why did my list stop updating" becomes a log-diving exercise instead of a query against one table. The corrective is unglamorous: make the granted scope set part of what identifies the row, populate it from what the provider granted, and have every write path consult it before it calls.

  • If both token columns are encrypted, what does that cost the query that finds rows due for refresh?
    Nothing, provided the columns the query filters on stay in plaintext. Expiry, due time, status, provider and the granted scope set are not secrets; the tokens are. Encrypting a column takes it out of every index and range predicate, so encrypting the due time would turn each sweep into a full-table decrypt for no security gain.
  • A user who connected for read-only now enables list writing — what happens to the existing row?
    It stays as it is. The application starts a fresh authorization for the wider scope set, and what comes back is a new grant that either supersedes the old row or sits beside it. Refreshing the old row can never produce the wider permission, and some providers only re-issue a refresh token when the authorization request carries `prompt=consent`.
  • Why record the scope from the token response rather than the scope your authorization request asked for?
    Because the user may have approved less than you asked for. The granted set is the authorization that exists; the requested set is a wish. If the row stores the wish, every downstream check believes the planner can write when it can only read, and the failure surfaces as `403` with `insufficient_scope` inside a background job.

saying these in an interview costs you the question

  • Thinks a wider permission can be obtained by refreshing the stored grant
  • Stores the scope the application requested instead of the scope actually granted
  • Keeps one row per user, ignoring which provider and which permissions
  • Encrypts the token columns but keeps the key inside the same database
  • Learns a permission is missing only when the provider answers 403 mid-job
  • Encrypts the expiry column too, then wonders why the sweep scans everything