Short answer: give each shared-data category its own grant, bind refresh-token rotation to that grant, and make recovery revoke the stolen session before asking for consent again. This is the least complex design that keeps a marketplace collaboration app from turning one approved calendar scope into a license for every workspace record.
I build RAG and agent features in Python, so I care about an authorization model that can be evaluated in a notebook and then run unchanged in production. The useful unit here is a small decision function: category, session, and recovery evidence go in; an allow or deny decision plus an audit reason comes out. Token rotation is a consequence of that decision, not a separate login feature.
Start With Categories, Not Screens
Imagine a marketplace workspace where buyers and sellers coordinate listings. The app requests three categories: profile (display name and avatar), orders (shared order status), and payouts (banking-related settlement state). A consent screen can show all three, but the server must store three grants. A user who withdraws payouts should not lose access to their profile, and a stolen browser session should not regain payouts merely because its refresh token is still cryptographically valid.
Store a grant with a stable subject, workspace, category, version, and timestamp. Keep the refresh token opaque to application code; hash it at rest and associate its family with the grant version. On every refresh, rotate the token and check the current grant. On recovery, increment the session family's revision and require fresh consent for any category that was revoked.
How Can a Collaboration App Enforce Per-Category Consent for Shared Authorization?
The following example is intentionally boring. It uses an in-memory repository so the policy is easy to test; replace that repository with your database and a transactional compare-and-swap. The important behavior is the ordering: revoke first, then rotate, then issue a token whose claims reflect the current category grants.
from dataclasses import dataclass
from datetime import datetime, timezone
from secrets import token_urlsafe
@dataclass
class Session:
family: str
revision: int
refresh_token: str
class ConsentStore:
def __init__(self):
self.grants = set()
self.sessions = {}
def grant(self, user_id, workspace_id, category):
self.grants.add((user_id, workspace_id, category))
def allowed(self, user_id, workspace_id, category):
return (user_id, workspace_id, category) in self.grants
def start_session(self, user_id, workspace_id):
family = token_urlsafe(18)
session = Session(family, 0, token_urlsafe(32))
self.sessions[family] = (user_id, workspace_id, session)
return session
def rotate(self, family, presented_token, category):
user_id, workspace_id, session = self.sessions[family]
if presented_token != session.refresh_token:
raise ValueError("refresh token rejected")
if not self.allowed(user_id, workspace_id, category):
raise PermissionError("consent required for category")
session.revision += 1
session.refresh_token = token_urlsafe(32)
return {
"access_token": token_urlsafe(24),
"refresh_token": session.refresh_token,
"category": category,
"issued_at": datetime.now(timezone.utc).isoformat(),
}
def revoke_stolen_session(self, family):
user_id, workspace_id, session = self.sessions[family]
session.revision += 1
session.refresh_token = token_urlsafe(32)
return {"user_id": user_id, "workspace_id": workspace_id,
"revoked_revision": session.revision}
store = ConsentStore()
store.grant("buyer-17", "market-4", "profile")
store.grant("buyer-17", "market-4", "orders")
session = store.start_session("buyer-17", "market-4")
store.revoke_stolen_session(session.family)
A production implementation should make the rotation update atomic and mark a reused token family as revoked. Do not log raw refresh tokens. Log the family identifier, category, consent version, and a reason code instead. Those fields make an incident explainable without creating a second secret leak. In a marketplace, that detail changes the incident response: the support agent can see that payouts was withdrawn at revision 8, while a background export still carried revision 7, and can stop the export without disabling the seller's unrelated profile access. The audit record also gives the evaluator a deterministic assertion: every post-recovery request must carry a revision at least as new as the one stored for the family. If the database transaction commits the grant change but the token update fails, the next refresh still sees the newer grant version and cannot silently restore the old scope. If the token update commits first, the grant check prevents the newly rotated token from acquiring a category that has already been withdrawn. That is why the two writes belong in one transaction or behind an equivalent compare-and-swap boundary.
That ordering matters.
Consent Withdrawal Is an Account-Recovery Path
Consent withdrawal is often treated as a settings toggle. In a collaboration app it is closer to account recovery because it changes what a session is allowed to see. The recovery flow should offer a short, explicit sequence: identify the workspace, show affected categories, revoke the session family, rotate all active refresh tokens for that family, and ask for new consent only where access is still wanted.
There is a subtle race here. A worker may have read orders before the user withdrew it, while an API request is trying to refresh payouts. Include a monotonically increasing consent version in both the grant row and the access-token claims. The API checks the version at the data boundary; the worker checks it before writing a result. This limits stale authority without pretending that already delivered data can be pulled back.
The catch is that category-level recovery adds user decisions. That is appropriate for shared financial data, but it can be too much friction for a read-only internal wiki. Stick with a coarser workspace grant when every category has the same sensitivity and the recovery owner is a trusted administrator. Use per-category grants when collaborators have different roles, when a stolen session is plausible, or when one category carries materially higher impact.
Evaluate the Flow Before Shipping It
I start with an eval table, not a polished consent modal. Each row names an actor, session state, category, and expected audit reason. A compact set catches most design errors:
| Case | Expected result | Evidence to retain |
|---|---|---|
Fresh session, granted orders
|
Allow and rotate | consent version, family id |
Fresh session, ungranted payouts
|
Deny and request consent | category, reason code |
| Stolen token after recovery | Deny and revoke family | revoked revision |
| Replayed prior refresh token | Deny and revoke family | reuse event, timestamp |
| Withdrawal during a worker job | Stop the write | version mismatch |
Run these cases against the policy function and the HTTP handler. Then replay them with clock skew, duplicate requests, and two concurrent refreshes. Your mileage may vary on storage isolation; I'm not sure a cache-only design can provide the same guarantees as a transactional database, so I would measure that explicitly rather than assume it.
Keep token lifetime, consent lifetime, and recovery evidence separate. A five-minute access token does not compensate for a refresh token that survives a week after a theft report. Require step-up authentication for high-impact categories, and make the recovery channel independent from the stolen session: a verified email, hardware key, or administrator-reviewed process can satisfy that role depending on the marketplace's threat model.
Before release, verify that every category has an owner, a data-minimization description, and a denial path that works without a vendor SDK. Verify that refresh rotation is atomic, token reuse revokes the family, and audit records contain no credentials. In deployment, alert on reuse and unusual category expansion, sample decisions for human review, and run the eval table in CI whenever policy or prompt code changes. This design is not suitable when your identity provider cannot expose consent versioning or when your team cannot operate durable audit storage. In that case, choose a simpler provider-managed session model and document the coarser recovery boundary. The trade-off is visible: fewer moving parts, less precise revocation. Precision is valuable only when the organization can support it.
References
- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- https://datatracker.ietf.org/doc/html/rfc6749
- https://datatracker.ietf.org/doc/html/rfc9700
Top comments (1)
Love the “category, session, evidence → decision + audit reason” idea. To push it: add a per-category risk level and step‑up rule into that function. Then your eval table can cover “same family, higher‑risk category” so
payoutsforces reauth even whenordersstill passes.