Controls · September 27, 2026

How to Revoke an Agent's Email Access Instantly

How to revoke an agent's email access: five kill switches ranked by blast radius, the exact calls, and what happens to mail already queued or held.

An agent just sent something it should not have, or its key turned up in a log file, or the person who set it up left this morning. You need to know how to revoke an agent’s email access right now, and you need two answers the revoke button does not give you: how long the old credential keeps working after you press it, and whether the message sitting in the send queue still goes out.

This article settles both. It ranks the five switches available in this build by blast radius, gives the exact call for each, and traces what happens to an open MCP session, a queued send and a held approval after each one. If you want the full permission model first, with every scope and how grants intersect, read agent email permissions and scopes and come back.

How to revoke an agent’s email access: pick the switch by blast radius

Use the narrowest switch that covers the incident. Every one of them takes effect on the next request, so the choice is about collateral damage, not speed.

SwitchCallWho can pull itStopsLeaves working
1. Revoke one API keyPOST /v1/keys/{key_id}/revokeAny credential with keys:manageThat key and every key minted from itOther keys, MCP connections, inbound mail
2. Revoke one MCP connectionPOST /v1/projects/{project_id}/connections/{connection_id}/revokeA human with inboxes:writeThat grant’s tool calls and its unsent queued mailAPI keys, other connections, inbound mail
3. Narrow the authorising humanNo self-service surface in this build (see below)Not customer-operable todayEverything that human minted, on the next callCredentials other people authorised
4. Pause sending on the inboxPUT /v1/projects/{project_id}/inboxes/{inbox_id}/policy with send_enabled: falseA current owner or adminAll outbound from that inbox, from every credential and every humanReading, drafting, inbound mail
5. Deactivate the projectPOST /v1/deletion-jobs with scope: "project"A human owner or adminRouting, sending, and every key and connection in the projectNothing in that project; no self-service undo

When the recommendation flips:

Revoke one API key (keys endpoint, keys:manage)

Find the key first. GET /v1/projects/{project_id}/keys lists key metadata without secrets, including id, prefix, scopes and a revoked flag, and it requires keys:manage. Match on the prefix, not the name: the prefix is the part of the secret that is safe to have in your logs, so it is the one you can join against.

curl -s "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/keys" \
  -H "Authorization: Bearer $EFA_ADMIN_KEY"

curl -X POST "https://api.emailforagents.ai/v1/keys/$EFA_KEY_ID/revoke" \
  -H "Authorization: Bearer $EFA_ADMIN_KEY"
# {"revoked":true}

Note the path. The revoke route is workspace-level, /v1/keys/{key_id}/revoke, not nested under the project like the list and create routes. Neither this route nor the connection revoke route below is in the published OpenAPI document today, so a client generated from that document will not have them; call them directly. If you would rather click, the console’s Access page lists the project’s keys with a revoke action that calls this same route.

The call is repeatable: revoking an already-revoked key keeps the original timestamp and returns {"revoked":true} again. These checks run before the row changes, and each failure is a 403 permission_denied, not a silent success:

After revocation the old secret gets HTTP 401 with code unauthenticated and the message “Invalid API key.” That is the same response an unknown or mistyped key gets, by design: the error does not confirm to whoever holds the secret that it once worked.

Revocation cascades down the key chain. Authority for a key is computed by walking from the key up through every parent it was minted from, and if any link in that chain is revoked or expired the whole chain is refused. A child key of a revoked parent gets 401 “Credential or its authorizer is no longer active.” One revoke on a per-service parent kills every per-worker child.

Revoke an MCP connection grant (connections endpoint)

An agent connected over MCP does not hold an API key at all. API keys are rejected at /mcp by design; the agent holds an OAuth connection grant, and that is what you revoke:

curl -s "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/connections" \
  -H "Authorization: Bearer $EFA_HUMAN_TOKEN"

curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/connections/$EFA_CONNECTION_ID/revoke" \
  -H "Authorization: Bearer $EFA_HUMAN_TOKEN"
# {"id":"...","status":"revoked"}

The console does the same thing from the Connections list. Two differences from key revocation matter under pressure.

Only a human can revoke a connection. The endpoint requires a human principal holding inboxes:write. An API key, even one with keys:manage, gets 403 “Only a human member can perform this action.” Your incident automation cannot kill an MCP grant with a service key; a person has to be signed in.

The call is not repeatable. It only updates a connection whose status is not already revoked, so a second call returns 404 not_found, “Connection not found.” If your runbook retries on failure, treat that 404 after a first success as done, not as a missing connection. The revocation is also written to a server-side audit record as connection.revoked; that is an internal audit action, not a customer event, and there is no endpoint to read it back.

What the open MCP session sees: nothing, until it makes its next request. Access tokens issued to MCP clients last 900 seconds, but the token is only a handle to the connection row. Each POST /mcp, including tools/list, resolves that row fresh, and a revoked row returns HTTP 401 unauthenticated, “Credential or its authorizer is no longer active.” The integration suite asserts exactly this: it revokes a connection and checks that the very next tools/list with the still-unexpired access token is 401. There is no push to the client, so an agent that is idle simply finds out on its next call. If the client then tries to exchange a refresh token, the token endpoint refuses with invalid_grant, “The connection is expired, revoked, or the credential was already used.”

Why there is no cached-token window: authority is re-read per request

Most revocation stories have a gap. A signed token carries its claims, the server trusts them until expiry, and “revoked” means “will stop working within the hour”. The Nylas CLI guide to revoking a user’s email access for an agent, marked updated 14 June 2026 and read here on 27 September 2026, is built around exactly that concern: it notes that an OAuth access token can stay valid for up to 3,600 seconds after a user thinks they disconnected, and that its refresh token can keep minting new ones. Its fix is sound and clearly written: list grants with nylas auth list --json, revoke by ID with nylas auth revoke, and confirm by listing again. It also makes a distinction worth copying, that nylas auth remove only drops the grant from local config and leaves it valid on the server. What it does not cover is mail the agent already handed over before you revoked.

Here the gap does not exist, for a structural reason. A credential here is a lookup handle, not a claim set. On every request the server loads the key or connection row, checks revoked_at, status and expiry, then recomputes effective permission from current database state: the key’s own scopes and inbox list, intersected with every parent key, intersected with the current grant of each human who authorised a link in the chain. The function that does this is documented in the source as reading “current SQL grants on every request/dispatch; token snapshots cannot restore revoked access.”

The consequence is the third switch in the table. Narrow the human, and every key and connection they authorised narrows with them on the next call:

The honest limitation: this build has no endpoint or console screen for changing a member’s role or assignments. The mechanism is real and tested, but you cannot pull it as a lever today. Treat it as the property that guarantees switches 1 and 2 cannot be outlived by a token snapshot, not as a button in your runbook.

What happens to mail already queued or held for approval

Revocation also reaches mail the agent submitted before you acted, up to one precise point. Accepting a send puts it in a queue; a job later claims it and hands it to the provider. That job re-runs the full send authorisation, in the same transaction that claims the message, against the credential that originated it: project, inbox, environment, messages:send (or drafts:submit for an approved draft), and current policy.

Where the message was when you revokedOutcome
Queued, not yet claimedCanceled at dispatch; no network call to the provider
Held for approval, not yet approvedApproving it fails with 401 unauthenticated; nothing sends
Approved by a human, not yet claimedCanceled at dispatch, same as queued
Claimed and being submitted to the providerGoes out. It cannot be recalled
Accepted by the providerGone. See accepted vs delivered

Three details in that table are easy to get wrong.

A canceled send makes no noise. The message state moves to canceled and any reserved quota is released, but no event is emitted. message.canceled is declared in the event vocabulary and never fired by this build, so a webhook handler waiting for it will wait forever. Poll the message’s state if you need to confirm what was stopped.

Approval does not rescue a revoked agent’s draft. The approve path re-authorises the send as the credential that requested it, not as the approver. After you revoke the agent, a human clicking approve on its pending draft gets a 401 carrying “Credential or its authorizer is no longer active.” That reads like the approver’s own session expired. It has not; the draft simply cannot be sent any more. Reject it, and have the human send a fresh message if it still needs to go. The approval flow is covered end to end in human approval for agent email.

The unrecallable window is short but real. Once the job has marked the send as submitting, the provider call proceeds. No switch on this page reaches a message past that point, and nothing anywhere recalls an email after the provider has accepted it.

Pause sending without cutting off reads (send_enabled=false)

Pausing is the right first move when you are not yet sure which credential is at fault. It denies every outbound send from the inbox, whoever makes it, and leaves the agent able to read, so the thread history stays inspectable while you investigate.

The policy write replaces the whole inbox policy and requires every field, so read it first and send back what you got with send_enabled flipped:

curl -s "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes/$EFA_INBOX_ID/policy" \
  -H "Authorization: Bearer $EFA_HUMAN_TOKEN"

curl -X PUT "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes/$EFA_INBOX_ID/policy" \
  -H "Authorization: Bearer $EFA_HUMAN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "expected_version": 3,
    "send_enabled": false,
    "approval_mode": "direct",
    "allow_domains": [], "allow_addresses": [],
    "block_domains": [], "block_addresses": [],
    "daily_recipient_limit": null
  }'

Use the version from the GET as expected_version. If someone else saved in between you get 412 version_conflict; read again and retry. Only a current owner or admin can write policy, and it must be a signed-in human, not a key.

A paused inbox denies new sends with reason send_disabled. The same policy check runs again at dispatch, so a message queued before the pause is canceled if it has not been claimed yet. A pause also overrides approval: a human-approved draft is still refused, because a policy deny is not something an approval can lift. Resuming does not replay anything that was canceled. The sending controls reference lists the rest of the precedence rules. The same switch is on the inbox’s controls in the console.

Deactivate the project when the inbox itself is compromised

Deactivation is the widest switch and the only one that stops inbound mail. It sets the project’s inboxes to deleting, disables address routing, and revokes every API key and every connection in the project in one pass.

curl -X POST "https://api.emailforagents.ai/v1/deletion-jobs" \
  -H "Authorization: Bearer $EFA_HUMAN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"confirmation":"DELETE","scope":"project","project_id":"'"$EFA_PROJECT_ID"'"}'

Always send scope explicitly. The field defaults to "workspace": a body with only "confirmation":"DELETE" asks to deactivate the entire workspace, and a body that carries project_id but omits scope is rejected as invalid rather than narrowed to the project. The call needs a human holding members:manage, which means an owner or admin. It returns 201 with a job in pending state and the work happens asynchronously; poll GET /v1/deletion-jobs/{deletion_id} until state reads tombstoned, because a queued request is not a completed one.

If you might need the mail later, run an export first; deactivation is a bad moment to discover you wanted the thread history. The steps are in export an agent’s email history.

There is no self-service undo, and deactivation is not erasure: stored mail, attachments and backups may remain. Read data controls before you use it on anything you might want back.

Prove the revocation took

A revoke call returning 200 means a row changed. It does not prove the agent is locked out. Run these checks with the credential you just killed, not with your admin one:

  1. Key: GET /v1/me with the old secret returns 401. That route needs no scope, so a 200 means the key is still alive and you revoked the wrong one. Check the prefix.
  2. Connection: GET /v1/projects/{project_id}/connections shows status: "revoked", and the client’s next tool call fails. Do not wait for the 900-second token to lapse; it is irrelevant here.
  3. Queue: list the inbox’s recent messages and look for canceled on anything the agent submitted in the last few minutes. Anything that reached the provider already is past recall; anything canceled was stopped. For a fuller sweep of what went out before you acted, see monitor what your agent emailed.
  4. Pause: GET the inbox policy and confirm effective_send_enabled is false. That field folds in inherited workspace and project policy, so it is the one to trust.
  5. Write it down. There is no customer-readable audit log in this build, so record which credential you revoked, when, and why, in your own incident notes.

What this build does not do: it does not notify the agent or its operator that access ended, it does not emit an event for revoked keys, revoked connections or canceled sends, and it does not let you narrow a person’s access from the console. What it does do is make every switch above final on the next request, with no token window to outlast. The security overview sets out the rest of what is enforced in code and what is not claimed.


Keep reading

All guides · Documentation · Controls