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.
| Switch | Call | Who can pull it | Stops | Leaves working |
|---|---|---|---|---|
| 1. Revoke one API key | POST /v1/keys/{key_id}/revoke | Any credential with keys:manage | That key and every key minted from it | Other keys, MCP connections, inbound mail |
| 2. Revoke one MCP connection | POST /v1/projects/{project_id}/connections/{connection_id}/revoke | A human with inboxes:write | That grant’s tool calls and its unsent queued mail | API keys, other connections, inbound mail |
| 3. Narrow the authorising human | No self-service surface in this build (see below) | Not customer-operable today | Everything that human minted, on the next call | Credentials other people authorised |
| 4. Pause sending on the inbox | PUT /v1/projects/{project_id}/inboxes/{inbox_id}/policy with send_enabled: false | A current owner or admin | All outbound from that inbox, from every credential and every human | Reading, drafting, inbound mail |
| 5. Deactivate the project | POST /v1/deletion-jobs with scope: "project" | A human owner or admin | Routing, sending, and every key and connection in the project | Nothing in that project; no self-service undo |
When the recommendation flips:
- You do not know which credential is misbehaving. Skip to switch 4. It stops the damage regardless of who is sending, and it keeps the audit trail readable while you look.
- The credential holds
keys:manage. Revoke it before anything else. A leaked key with that scope can mint child keys and revoke your narrower ones. You do not have to hunt the children down: every key it minted records it as parent, so revoking it kills them in the same step (see the cascade below). - The inbox address itself is the problem (it is on a list, it is being spoofed at,
it received something it should not keep receiving). Only switch 5 stops inbound
routing. There is no per-inbox delete endpoint in this build, even though an
inboxes:deletescope exists in the vocabulary.
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:
- The caller must hold
keys:manageand have access to the key’s project. - The caller cannot revoke a broader credential. If the target key holds any scope the caller lacks, the call fails with “Cannot administer a broader credential.” Keep one owner-level credential for incident response rather than discovering this with a narrowly scoped admin key.
- A key with no inbox restriction can only be revoked by a caller that is itself unrestricted across inboxes.
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:
- If their membership stops being active, every credential they authorised returns 401, “Credential or its authorizer is no longer active.”
- If a developer or operator loses their assignment to a project, every key they minted loses that project on the next request, with no revocation step. Owners and admins are not bounded by project assignments, so this only narrows non-admin roles.
- If their role narrows, the scopes on their keys narrow to match. A role that no longer
includes
messages:sendproduces keys that no longer send, whatever they were minted with.
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 revoked | Outcome |
|---|---|
| Queued, not yet claimed | Canceled at dispatch; no network call to the provider |
| Held for approval, not yet approved | Approving it fails with 401 unauthenticated; nothing sends |
| Approved by a human, not yet claimed | Canceled at dispatch, same as queued |
| Claimed and being submitted to the provider | Goes out. It cannot be recalled |
| Accepted by the provider | Gone. 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:
- Key:
GET /v1/mewith 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. - Connection:
GET /v1/projects/{project_id}/connectionsshowsstatus: "revoked", and the client’s next tool call fails. Do not wait for the 900-second token to lapse; it is irrelevant here. - Queue: list the inbox’s recent messages and look for
canceledon anything the agent submitted in the last few minutes. Anything that reached the provider already is past recall; anythingcanceledwas stopped. For a fuller sweep of what went out before you acted, see monitor what your agent emailed. - Pause:
GETthe inbox policy and confirmeffective_send_enabledisfalse. That field folds in inherited workspace and project policy, so it is the one to trust. - 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
Scoped Credentials for Agent Email: Read, Draft, Send
Give an agent the smallest useful email permission: project and inbox scoping, one-time secrets, draft-only grants, rotation, and revocation you can verify.
Human Approval Before an AI Agent Sends Email
When to require review, why approval binds to one exact draft version, how recipient rules and daily caps work, and what editing a draft does to its approval.
The Agent Email Threat Model
Assets, actors, trust boundaries and eight threats for an agent with an email address, with the layer where each control binds and where ours stops short.