Delegated Transport, Forked Authorization: Where Chat and AI-Agent Integrations Actually Break
A Python simulation exposes authorization gaps in third-party chat integrations, from delayed access revocation to AI agent misuse, and tests controls to prevent them.
When you move messaging onto a third-party platform, you don't hand off the authorization problem. You split it across two systems. Most incidents live in the gap. A simulation harness, and a documentation audit of QuickBlox as a worked example.
The Revoked User Who Kept Reading
Say a clinic's app removes a contractor's access to a patient thread at 14:02. The app's database says so, the UI hides the thread, and the audit table has the row. The contractor's phone keeps receiving new messages until 15:00, and then keeps going.
Nothing in the application is wrong. The membership removal went to the chat platform's API as a single best-effort call, and the API was having a bad ten minutes. The call failed, and the exception was swallowed. The contractor's session token is still valid, and the contractor's client keeps making requests that keep it valid.
This is a hypothetical, but every piece is routine: a removal path with no retry, an admin API that can be unavailable, a session whose lifetime depends on activity. The useful question is how long the window lasts, and it becomes measurable once you stop treating the integration as an API call and start treating it as a consistency boundary.
This article builds a small deterministic harness, seamlab.py, to measure that window along with seven other failures at the same boundary, including an AI agent acting after a human has taken over a conversation. It then audits one real platform, QuickBlox, against the same questions using its public documentation. QuickBlox is the worked example because its developer documentation is public and detailed enough to check. The harness measures a class of platforms and nothing about any vendor. The documentation audit reads documentation and tests nothing. I keep those two kinds of evidence apart throughout.
The Flawed Assumption
The usual framing of "build vs. buy" for chat or calling is about transport: delivery, ordering, presence, media. Delegating those is reasonable. But "who may read this thread, who may post, who may trigger an action" is not a transport concern, and delegating transport forks it:
- Your application holds the relationships: this clinician treats that patient, this contractor was removed.
- The platform holds a projection of them: dialog memberships, session tokens, message history.
The projection is only as fresh as your last successful write to it, and the platform enforces the projection, not your database. So at least two places answer "is this allowed," and they can disagree. Each failure below is a case of that disagreement, or of one side believing a claim the other never checked.
Two quantities make this concrete.
- Revocation window W: the time between the app committing a revocation and the last successful read by the revoked principal. If the platform checks live membership on every read, W is bounded by the time until the removal call lands. If it authorizes from the session token alone, W is bounded by the token's remaining life, and whether that life is fixed or extended by activity decides everything.
- Authority provenance: for any action in the system, whose authority is it exercising? For a human action, the answer is trivial. For an AI agent in a multi-party conversation, it usually isn't.
Threat Model
Six attacker positions, all realistic for a chat integration:
| # | Attacker | Capability |
|---|---|---|
| A | External | Knows the webhook URL |
| B | Authenticated low-privilege participant | Can post messages into a thread the agent reads |
| C | Self-registered platform user | Platform allows signup, no verified binding to your user model |
| D | Revoked participant | Holds a valid token from before revocation |
| E | Replayer | Has a captured, validly signed event (logs, proxy, compromised sink) |
| F | Writer on the audit store | Can modify rows |
Trust boundaries: client to app, app to platform (admin API), platform to app (event delivery), agent to tools, and the audit store.
| Class | Precondition | Mechanism | Test | Control |
|---|---|---|---|---|
| Forged event | A | Unauthenticated event endpoint accepted | forged | Signature verification |
| Identity confusion | C | Sender resolved by mutable attribute (email) | spoofed identity | Immutable-ID binding table |
| Replay | E | Dedupe TTL shorter than replay horizon | replay | Timestamp window plus retention ≥ 2×window |
| Duplicate side effect | Normal at-least-once delivery | Non-atomic check-then-act | race | Atomic claim before acting |
| Stale access | D | Token and membership outlive revocation | revocation sweep | Short TTL, outbox retry, token kill |
| Agent confused deputy | B | Injected text drives a privileged tool | A1–A4 | Parameter-bound approval |
| Post-handoff action | Race with human takeover | Agent call in flight at handoff | A5–A6 | Epoch fencing |
| Scope overreach | B | Agent reads history beyond session | A7 | Capability-scoped history |
Audit tampering (F) is covered separately below.
The Harness
seamlab.py is about 480 lines of stdlib Python on a logical clock, with no network and no randomness. It contains:
- A platform simulator: at-least-once event envelopes signed with HMAC, expiring tokens with scopes frozen at mint, optional idle (sliding) expiry, self-service registration, and an admin API that can be switched off. Whether reads check live membership, and whether a server-side token kill exists, are switches.
- Two app implementations: a naive one and a hardened one.
- An agent world with a tool proxy configurable per control, so the table shows which control closes which attack.
- A worst-case agent: it obeys every
CALL tool key=valueline in its context. That is deliberate. The tests target the authorization layer and say nothing about how robust any model is to injection. If your model resists injection, that's a bonus. These controls assume it doesn't.
The duplicate-delivery test enumerates every distinct interleaving of two workers handling the same event, rather than sampling a few.
Naive Design, Then the Attacks
The naive ingress handler is a shape I'd expect to find in plenty of codebases: no authentication on the endpoint, dedupe with a TTL, check then act then record, sender resolved by email.
def handle(self, env): t = self.seen.get(env["id"]); yield # check if t is not None and self.clock.t - t < self.DEDUPE_TTL: # 300s return "dup" uid = self.resolve(env["body"]["sender"]) # email match if uid: self.run_command(uid, env["body"]["text"]) # act yield self.seen[env["id"]] = self.clock.t; yield # record
| Attack | Naive | Hardened |
|---|---|---|
| Forged unsigned event | succeeds | blocked |
| Validly signed event, spoofed identity | succeeds | blocked |
| Replay at t+225 s | blocked | blocked |
| Replay at t+3600 s | succeeds | blocked |
| Duplicate delivery: interleavings producing a double effect | 60 of 66 | 0 of 12 |
The spoofed-identity row is the interesting one. The event carries a correct platform signature. Nothing was forged. The attacker registered a platform account with email [email protected], unverified. The naive app lowercases and matches against its user table, so a perfectly authentic message from the wrong person runs with the clinician's authority. Signature verification can't help, because the signature is telling the truth about who sent it. The control is a binding table keyed on the platform's immutable user ID, written only by your backend when it provisions the platform user for an authenticated app user. The attacker's ID has no binding, so the event is rejected as unbound.
The race result is worse than it looks. 60 of 66 distinct schedules double-fire. Only interleavings where the first worker finishes recording before the second starts checking are safe. This is the ordinary case for any retry storm. The fix is one atomic operation (SET NX EX, or an insert against a unique constraint) before any side effect.
The hardened handler:
def handle(self, env): if not hmac.compare_digest(env.get("sig", ""), sig_of(env)): return "bad_sig" if abs(self.clock.t - env["ts"]) > self.TOL: return "stale" ok = self.claim(env["id"]); yield # atomic set-if-absent, retention 2*TOL if not ok: return "dup" uid = self.bindings.get(env["body"]["sender"]) if uid is None: return "unbound" self.run_command(uid, env["body"]["text"]); yield return "ok"
Mutation-Testing the Control
The suite should be able to fail the hardened design; otherwise, the green column is decoration. So I mutated one parameter: dedupe retention set to half the timestamp tolerance. The replay at t+225 s then succeeds, because the event is still inside the accepted time window while its dedupe record has expired. The replay at t+3600 s is still blocked by the timestamp check.
That yields an invariant worth writing into code review: dedupe retention must be at least twice the accepted timestamp skew (once for events slightly in the future, once for slightly in the past). Timestamp validation and dedupe are two halves of one control, and shortening either silently opens the gap.
One caveat: claim-before-act gives at-most-once side effects if the process dies between claim and effect. For effects that must not be lost, the downstream operation needs its own idempotency key derived from the event ID. The harness does not model crash recovery.
Revocation Window, Measured
The harness drives a revoked user who keeps polling and refreshing, with the token's age at revocation sampled across its lifetime. The scenario that matters: the admin API is down when the revocation is issued and comes back 600 s later. In the first table, the platform checks live membership on every read:
| Config | Worst W (s) | Mean W (s) | Token mints per user per hour |
|---|---|---|---|
| Naive, TTL 3600, single attempt | 3600 | 1830 | 1 |
| Hardened, TTL 30 | 30 | 16 | 120 |
| Hardened, TTL 60 | 60 | 30 | 60 |
| Hardened, TTL 300 | 300 | 152 | 12 |
| Hardened, TTL 900 | 601 | 405 | 4 |
| Hardened, TTL 3600 | 601 | 556 | 1 |
"Hardened" means a transactional outbox retrying the removal with capped backoff, a broker that re-checks the app's authorization on every token mint, and a short TTL. Once TTL exceeds the outage, W is capped by the outage plus up to one backoff interval (601 s in this run). Below the outage length, TTL is the binding constraint. With a healthy admin API, every configuration, naive included, shows W = 0. The naive design only fails when the platform is degraded, which is when nobody is watching.
When the Platform Doesn’t Check Membership, and Tokens Slide
Two platform properties change the picture, and I only added the second after reading the vendor documentation described below, which is why the harness changed between drafts.
If reads are authorized from the token alone, removal calls don't matter, and the TTL is the only built-in lever. If the token's expiry is idle-based, so each request pushes it out, then a revoked client that keeps making requests never lets the token expire, and the TTL stops being a bound at all. The broker's re-check at refresh never runs, because refresh never happens.
The last control available is a server-side token kill, if the platform offers one. Results for a token-only platform (hardened TTL 60, naive TTL 3600; "never ends" means access persisted to the end of the simulated horizon):
| Expiry model, admin API | Naive | Hardened, no token kill | Hardened + token kill |
|---|---|---|---|
| Fixed TTL, healthy | 3600 s | 60 s | 0 s |
| Fixed TTL, 600 s outage | 3600 s | 60 s | 60 s |
| Idle expiry, healthy | never ends | never ends | 0 s |
| Idle expiry, 600 s outage | never ends | never ends | 601 s |
Read the third row twice. With idle expiry and no kill, the short TTL and the broker re-check buy nothing against a client that keeps talking. The only controls that end access are live-membership enforcement on the platform or an invalidation you can trigger from the server. So the most valuable hour you can spend before integrating a platform is finding out which of those you have.
At TTL 60, each active user generates 60 token mints per hour. Whether that is trivial depends on your user count and the platform's rate limits, which the harness does not model.
The AI Participant: Confused Deputy, Not “Authorization for Bots”
A multi-party conversation is a prompt authored by several principals with different privileges. The agent reading it holds one set of tool credentials. Any principal who can put text in the thread can attempt to spend authority they don't have. This is the classic confused deputy, with the deputy now parsing natural language.
The harness has a dialog with a patient and a clinician, and three tools: get_my_record (low privilege), approve_refill (clinician only), and read_history. The proxy runs in three configurations: naive (the agent's standing credential decides everything), role-check (the call executes with the authority of whoever authored the triggering message), and hardened (role check plus parameter-bound approval, epoch fencing, and history clamping).
| Attack | Naive | Role-check only | Hardened |
|---|---|---|---|
A1 Patient's message steers agent into approve_refill |
succeeds | blocked | blocked |
| A2 Clinician's benign turn; patient's injected text sits in context | succeeds | succeeds | blocked |
| A3 Approval for rx42 reused to approve rx99 | succeeds | succeeds | blocked |
| A4 One approval consumed twice | succeeds | succeeds | blocked |
| A5 In-flight agent call executes after human takeover | succeeds | succeeds | blocked |
| A6 New agent turn minted after takeover | succeeds | succeeds | blocked |
| A7 Agent reads history outside session scope | succeeds | succeeds | blocked |
| B1 Clinician approves refill through explicit approval | works | works | works |
| B2 Patient reads own record through agent | works | works | works |
The B rows make sure the hardened column isn't green merely because it denies everything.
A2 is the finding that changed my plan. My pre-run design was authority as the intersection of the acting principals' rights. Role-check is that rule, and it stops the obvious attack (A1). It fails A2 completely. The clinician sends "please summarize the thread." The agent's capability is legitimately the clinician's. The patient's earlier message, still in context, asks for a refill approval, and the agent obliges, spending authority that was never granted for that purpose. Intersection scopes who the agent acts for. It says nothing about what that authority was meant to cover.
The fix is to stop treating conversation turns as grants for privileged operations. A privileged call needs an approval bound to the exact call, issued out-of-band by an authenticated UI session of a privileged human:
def _approval_ok(self, tool, args, ap): b = ap["body"] good = hmac.new(APP_KEY, json.dumps(b, sort_keys=True).encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(good, ap["mac"]): return False if (b["tool"], b["args"], b["epoch"]) != (tool, args, self.epoch): return False if self.clock.t > b["exp"] or b["nonce"] in self.used: return False if self.users.get(b["principal"]) != "clinician": return False self.used.add(b["nonce"]); return True
The approval binds tool, arguments, epoch, expiry, and a single-use nonce. A3 fails because the arguments differ, and A4 because the nonce is spent. Injected text can still request the call. It cannot carry the authority to make it. The system doesn't try to detect injection. It makes sure injected text can't hold a capability.
Fenced handoff (A5, A6). When a human takes over, the app increments a per-dialog epoch. Every capability carries the epoch it was minted under, and the tool proxy rejects stale ones. A5 is the race: a turn starts at t=0, the human takes over at t=5, and the agent's call arrives at t=8. Without the fence it executes. This is a fencing token, and it has a fencing token's limit: it only works if the resource performing the effect checks the epoch. If the agent can also post through a path that bypasses your proxy, such as the platform's own API with the agent's own credentials, the fence is decorative. The platform-side complement is removing the agent from the dialog at handoff, which is another admin call, subject to the same outage window measured above, and which not every dialog type allows (see below).
History scope (A7). The capability carries the index where the current session begins, and read_history refuses anything earlier. The harness checks the proxy's enforcement. It does not check that the agent's context assembly respects the boundary, which is separate code you would need to test.
The audit log, and what a hash chain does not do
The hardened path records each agent tool call (allowed or denied) and each handoff in a hash-chained log with actor type, model version, tool, arguments, result, and principal:
- Clean chain, verified against an externally stored head hash: passes.
- Row modified without recomputing the chain: detected.
- Row modified and the chain recomputed from that row forward (an attacker with write access): verifies clean unless you compare against the externally stored head, in which case it is detected.
The chain alone defends against accidental corruption and clumsy tampering. It does not defend against someone who can write the store and knows the scheme. What does is anchoring the head hash somewhere that an attacker can't write: a separate account, an append-only object store, a periodic signed checkpoint. If your threat model includes a malicious insider with database access, the anchor is the control, and the chain is the mechanism that makes anchoring cheap.
On compliance: in a regulated setting, "who did what, when, under whose authority" is what this log makes answerable. The HIPAA Security Rule's audit-control standard is 45 CFR §164.312(b) (cited from memory; confirm before relying on it). A platform's compliance posture does not discharge your obligations, and a log design is one input to them, not compliance itself.
A Documentation Audit: The Seam on Quickblox
Everything above is simulation. This section asks the checklist questions of a real platform using its public developer documentation, retrieved on 4 October 2026. Nothing here was tested against a live QuickBlox account. Documentation can be incomplete or out of date, and where I could not find an answer, I say so instead of guessing. Sources are listed at the end.
| Question | What the documentation says | Consequence for the seam |
|---|---|---|
| 1. Are server-to-app events authenticated? | I found no first-party documentation of outbound server-to-server event webhooks. A competitor's comparison page asserts QuickBlox doesn't support webhooks. That is a competitor's claim and I haven't confirmed it. | If there is no signed event feed, the forged/replay/race tests apply to whatever you build instead. The rule that survives is to never let your backend act on events reported by clients, because client-reported events are attacker-controlled. If you poll, poll with a server credential and treat the response as the source. |
| 2. Does a read check live membership, or only the token? | Not stated in the pages I read. | Unknown. This decides which row of the revocation tables applies. It is a two-minute sandbox test: remove an occupant, then read with that user's still-valid token. |
| 3. Token lifetime | Both user and application session tokens expire 2 hours after the last REST request, and each REST response carries an expiry header. I found no configuration for it. | This is idle expiry, which is the dangerous row of the table above. A client that keeps making REST calls keeps the session alive. I don't know whether live chat-connection activity also extends it, and I haven't verified an absolute cap. |
| 4. How is membership removed? | Removal goes through the dialog update call. A dialog's owner can remove any occupant (the REST reference says admins can too), and an occupant can remove only themselves. The SDK and dashboard docs show removing other users working for group dialogs, not for private (one-to-one) or public ones. Deleting a user removes them from group dialogs but not from private dialogs' occupant lists. | Private dialogs have no documented remove-another-user operation. If revocation matters, a clinician-patient conversation modeled as a private dialog can't be revoked by removal. You'd have to delete the dialog for everyone (which needs the owner and loses history). Model sensitive conversations as group dialogs with an app-controlled owner. This is a design decision to make before launch, not after. |
| 5. Immutable IDs, self-registration | User creation with an application session is documented as acceptable for prototypes. For production the docs recommend creating users from your backend with the API key and disabling application-session user creation. Users carry an external_user_id field intended to link to an outside system, and login, email and external ID must be unique. |
The identity-confusion attack depends on that production setting. Bind your app user to the platform's own user ID, written only by your backend, and don't resolve senders by login or email. external_user_id is a convenient mapping field, but the binding should still be enforced on your side. |
| 6. Can the agent be a distinct principal? | Dialogs have owner, admin, and occupant roles. | Make the agent a separate user and an occupant, never an admin or owner. To fence it at handoff by removing it, the dialog must be a group dialog, per row 4. I did not verify whether the agent's session can be invalidated server-side. |
| 7. Can you reconcile after a missed event? | Dialog and message REST endpoints exist, and the update and delete references show them taking an API key. I did not verify listing and filter semantics for drift detection. | Plan the reconciler as a poll of dialogs and occupants compared with your database. Confirm pagination and filter support in a sandbox first. |
Three observations from the table, none of them about vendor quality. Every platform has choices like these, and every one is a place the seam can fail:
- The documented 2-hour idle expiry changes the revocation answer. In the harness's token-only, idle-expiry rows, short TTLs and broker re-checks buy nothing, and the only thing that ends access is membership enforcement or a server-side kill. Whether QuickBlox enforces membership at read time is the first thing to test, and whether you can invalidate a session from the server is the second.
- The private-dialog limitation is the most actionable finding. It is a design constraint you can only avoid by choosing group dialogs up front.
- The absence of a documented signed event feed moves work to you. The ingress hardening in this article becomes a design for whatever sync mechanism replaces it.
On the vendor's compliance statements: QuickBlox's own pages state that it offers HIPAA-ready hosting with a signed BAA, and that it is SOC 2 certified. I haven't verified those claims, and a BAA does not make your application compliant. The seam problems in this article are exactly the part a BAA doesn't cover.
Cost and trade-offs
- Ingress overhead. Hardened ingress costs about 8.8 µs per event against 2.0 µs for the naive handler, in-process, on one machine (Python 3.12). Indicative only. In production, the dominant cost is one atomic store write per event.
- Token churn. Short TTLs convert revocation latency into mint load: 60 mints per user-hour at TTL 60, 120 at TTL 30. And if expiry is idle-based, TTL tuning may not be available to you at all.
- Dependence on the admin API. Forced removal and token kill are only as available as that API. The outbox hides outages from the revoking caller. It doesn't shorten them.
- Approval friction. Every privileged action needs a human click bound to specific arguments. The agent can't autonomously perform privileged operations. That is the intended outcome, and it will frustrate some product teams. Mitigate it with a good approval surface: show the exact arguments, make approval one tap, and expire quickly.
- Complexity. The hardened design adds a broker, an outbox, an approval service, a per-dialog epoch, and an anchored log. For an internal tool with a few trusted users, it is probably more than you need.
Limitations of this work
- The simulator is simple: one dialog, instantaneous admin success when up, no clock skew, no partial failures inside the platform, a single-node claim store.
- The worst-case agent models the threat, not any LLM.
- I did not implement or test a membership reconciler, although I recommend one.
- The QuickBlox audit is documentation-only. Docs may be incomplete, inconsistent (the owner-versus-admin removal rule is stated differently in different pages), or out of date. Every "unknown" in the table is a sandbox test I haven't run.
- Remaining exposure none of this addresses: injected text can still influence unprivileged tools (what the agent says, what it summarizes), exfiltration through in-scope reads, and approval fatigue.
- Nothing here shows that any real platform is vulnerable to any attack in the harness.
Conclusion
The integration boundary is a consistency boundary, and the interesting failures are state disagreements and authority crossing it unchecked. The revocation window gives you a number to argue about, instead of a feeling, and the number depends on three platform properties you can look up before writing code: whether reads check live membership, whether token expiry is fixed or idle-based, and whether you can remove a member or kill a session from the server. For the AI participant, the real question is whose authority a tool call exercises. Intersecting principals' rights is necessary but not sufficient. A privileged call needs an approval bound to its exact arguments, or conversation text will eventually carry authority it shouldn't.
Sources (QuickBlox documentation, retrieved 4 October 2026):
- Key Concepts: https://docs.quickblox.com/docs/key-concepts
- Authentication (JS): https://docs.quickblox.com/sdks/js-authentication
- Update Dialog: https://docs.quickblox.com/reference/update-dialog
- iOS Dialogs: https://docs.quickblox.com/sdks/ios-chat-dialogs
- Dashboard, Chat: https://docs.quickblox.com/docs/chat-dashboard
- Dashboard, Users: https://docs.quickblox.com/docs/users-dashboard
- Create User: https://docs.quickblox.com/reference/create-user
- Delete Dialog: https://docs.quickblox.com/reference/delete-dialog
Opinions expressed by DZone contributors are their own.