DEV Community

Anakin
Anakin

Posted on

Design API Access Revocation So Users Know What Actually Gets Deleted

Most APIs document the happy path for connecting a credential. Fewer document the exit path. That becomes a real problem during offboarding, incident response, or vendor reviews, because “disconnect” can mean anything from “stop refreshing this token” to “delete every stored secret right now.” Those are not the same operation, and treating them as one creates surprises.

If your system stores credentials, sessions, OAuth refresh tokens, service account bindings, or imported browser sessions, revocation needs a clear model. Users should know what stops working, what gets deleted, and what survives.

Separate the things people revoke

A common mistake is to expose one vague endpoint:

DELETE /connections/github
Enter fullscreen mode Exit fullscreen mode

That endpoint tells the caller almost nothing. Does it delete the connection record? The tokens? The linked user identities? Cached data? Running jobs? If the answer depends on flags or internal state, someone will eventually use it wrong.

A clearer model splits revocation by scope:

DELETE /sessions/{session_id}
DELETE /identities/{identity_id}
DELETE /sources/{source_id}
DELETE /sources/{source_id}/identities
Enter fullscreen mode Exit fullscreen mode

Those endpoints imply different outcomes:

  • Deleting a session removes one stored login or token. The account identity can remain. The next task should fail with something explicit, such as AUTH_EXPIRED, until someone reconnects.
  • Deleting an identity removes one connected account and every session underneath it. Other identities from the same source should remain untouched.
  • Disconnecting a source can remove the link to the vault or provider while keeping existing identities. That works for migration, but it is not complete offboarding.
  • Disconnecting a source and deleting its identities removes both the connection and all credentials sourced from it.

Wire uses this kind of split for credential revocation, where deleting a session, deleting an identity, and disconnecting a source are different operations with different failure behavior.

The important part is not the exact URL naming. The important part is that the destructive operation has its own shape.

Do not hide destructive behavior behind a flag

This is tempting:

DELETE /sources/{source_id}?delete_identities=true
Enter fullscreen mode Exit fullscreen mode

It looks convenient, but it makes the safe path and the destructive path one parameter apart. That matters more than it seems. Query strings get copied into tickets. Bookmarks get reused. Retry code can accidentally preserve a flag that the next caller did not intend to send.

Prefer a separate endpoint for the irreversible operation:

DELETE /sources/{source_id}
DELETE /sources/{source_id}/identities
Enter fullscreen mode Exit fullscreen mode

Then make the destructive endpoint atomic. If the source is removed but half the identities remain, you created a cleanup problem and probably a security exception.

In SQL, that usually means wrapping the operation in one transaction:

BEGIN;

DELETE FROM sessions
WHERE identity_id IN (
  SELECT id FROM identities WHERE source_id = $1
);

DELETE FROM identities
WHERE source_id = $1;

DELETE FROM sources
WHERE id = $1;

COMMIT;
Enter fullscreen mode Exit fullscreen mode

If anything fails, rollback the whole thing:

ERROR: update or delete on table "identities" violates foreign key constraint
Enter fullscreen mode Exit fullscreen mode

That error should not leave the system in a half-disconnected state. Either the credential set still exists, or it is gone.

Be precise about what “delete” means

“Deleted” often means “we set deleted_at and hide it in the UI.” That may be fine for many product records. It is not always fine for secrets.

For credential material, you should decide and document which of these you mean:

-- Soft delete
UPDATE sessions
SET deleted_at = now()
WHERE id = $1;

-- Hard delete
DELETE FROM sessions
WHERE id = $1;
Enter fullscreen mode Exit fullscreen mode

A soft delete can support restore flows and audit workflows, but the encrypted token still exists. If an attacker later gets the encryption key and database, the material may still be recoverable.

A hard delete removes the row. You can still keep an audit event without keeping the secret:

{
  "event": "session.deleted",
  "session_id": "sess_123",
  "identity_id": "id_456",
  "deleted_by": "user_789",
  "deleted_at": "2026-08-05T10:15:00Z"
}
Enter fullscreen mode Exit fullscreen mode

This gives compliance and debugging teams something to inspect without retaining the credential itself.

Also be clear about timing. If deletion happens synchronously, say so. If you enqueue it, expose the pending state:

{
  "status": "deletion_pending",
  "job_id": "job_abc"
}
Enter fullscreen mode Exit fullscreen mode

Do not let users assume a nightly sweeper exists if it does not.

Treat external revocation as the final authority

If a customer grants access through their own vault, IAM role, OAuth app, or service account, the strongest revocation point is outside your system. They can delete the service account, remove the role assignment, revoke the OAuth app, or rotate the secret.

That matters because it does not depend on your API being reachable or your deletion path behaving correctly.

Your system should detect that external revocation and stop retrying forever. For example, if a read from a vault starts failing with 403 Forbidden, mark the source as revoked:

{
  "source_id": "src_123",
  "status": "revoked",
  "last_error": "VAULT_ACCESS_DENIED",
  "last_checked_at": "2026-08-05T10:20:00Z"
}
Enter fullscreen mode Exit fullscreen mode

Then surface dependent identities as broken instead of returning stale data or hiding the failure behind a generic retry state. In systems like Wire, this distinction matters because vault revocation cuts off future reads even if stored identities still exist.

Running jobs are a separate edge case

Revocation usually affects the next operation, not necessarily the one already in progress.

If a worker loads a session at job start, deleting the session record 500 milliseconds later will not erase the copy already held in memory:

const session = await loadSession(sessionId);
await runLongScrape(session); // revocation during this call may not interrupt it
Enter fullscreen mode Exit fullscreen mode

If that window matters, you need an active cancellation mechanism:

await jobs.cancelBySession(sessionId);
Enter fullscreen mode Exit fullscreen mode

Even then, cancellation depends on workers checking cancellation state. For sessions controlled by a third-party site, the only reliable way to invalidate a copy already in use may be signing out everywhere or revoking the token at the original provider.

A practical checklist

For your own API, write down the revocation contract in plain terms:

  • What deletes one session?
  • What deletes an identity and all its sessions?
  • What disconnects a source but preserves identities?
  • What deletes both the source and sourced identities?
  • Which deletes are hard deletes, and which are soft deletes?
  • What happens to jobs already running?
  • What error does the next task return?
  • How does the system surface external revocation?

Then add tests for each path. The easiest test is also the most useful: create a source with two identities, add sessions under both, call each revocation endpoint, and assert exactly which rows remain.

Top comments (0)