Why do GET endpoints that mutate data need write approval?
GET endpoints that mutate data invite retries, previews, and accidental actions. Find hidden writes, redesign contracts, and require approval before dispatch.

A GET endpoint that changes state is a write wearing the wrong uniform. The danger is not academic. Browsers, crawlers, API clients, monitoring tools, link preview services, caches, and agents all make extra GET requests because the protocol tells them that doing so is safe. If your endpoint cancels a job, rotates a token, sends a message, or alters a record, those extra requests can become production actions.
Teams often find these routes after a strange incident, then patch the single endpoint that caused pain. That is too narrow. You need to find every read-shaped call that changes something, classify the effect by consequence, and put it behind the same authorization and audit boundary as an explicit write. Changing the verb matters, but it is only one part of the repair.
Safe HTTP methods describe the requested semantics
GET endpoints that mutate data violate the promise behind the method, even when their authors had a practical reason for choosing GET. RFC 9110 defines GET as a safe method and explains that a safe request does not ask the server to change state. The RFC allows incidental effects such as logging and accounting because the client did not request those effects. It does not excuse a route whose intended job is to make a change.
That distinction catches a common dodge: "The server must update last_seen when it reads the item." If the client asked to retrieve an item and the service updates an internal access timestamp, that may be incidental. If the client asked to retrieve an item and the service marks an invoice paid, creates an export, consumes a token, or advances a workflow, the mutation is the requested operation. Call it a write.
RFC 9110 also gives the operational reason to care. User agents can automate safe methods. A browser may fetch a page to construct a preview. A crawler may follow a link. A client library may retry after it loses the response. Protocol semantics give those actors permission to behave that way. Your server does not get to rely on every caller having read its undocumented exception.
Do not confuse safe with idempotent. A DELETE can be idempotent because repeating it leaves the resource deleted, yet it is still unsafe because the first call changes state. A GET that increments a counter once per request might be idempotent only in the narrow sense that it stops after a threshold, but it is still unsafe because its purpose changes state. These words answer different questions:
- Safe asks whether the caller requested a state change.
- Idempotent asks whether repeating the same request has the same intended effect.
- Cacheable asks whether an intermediary may reuse a response.
When a team blurs those terms, it often applies retry protection and decides the problem is solved. Duplicate prevention helps. It does not stop a link scanner from performing the first destructive action.
Search for consequences, not suspicious route names
You find hidden mutations by tracing what a route causes, not by trusting a route name or its verb. Routes called getReport and view can enqueue work. Routes called reset can be perfectly harmless if they return a form. Build your inventory from runtime evidence and code paths together.
Start with every handler registered for GET and HEAD. For each handler, trace direct writes and handoffs: database transactions, queue publishing, cache invalidation with business meaning, email or chat delivery, payments, credential changes, file deletion, and outgoing calls to another service. A GET handler that calls an internal service may look clean in its own repository while that internal call performs the mutation. Follow it until you can name the final effect.
This simple search catches plenty of old code:
rg -n 'GET|\.get\(|router\.get\(|app\.get\(' src
rg -n 'INSERT|UPDATE|DELETE|enqueue|publish|sendMail|charge|revoke|rotate' src
The output shape is less useful than the review record you create from it. Give each finding an endpoint, trigger, final effect, affected system, and caller list. Do not write "updates status" in the effect column. Write "marks deployment d-481 cancelled and sends the cancellation to the scheduler." Vague entries let a reviewer wave through a serious action.
Runtime data finds what source review misses. In a non-production environment, send representative requests with a correlation ID. Then query application logs, job tables, outbound-call logs, and audit records for that ID. If a GET request leads to a message, row change, queue item, or external request, record the complete chain. A route can mutate through a scheduled worker several seconds later, which makes a request log alone misleading.
Watch for mutations that developers dismiss because they are not relational database writes. Generating a single-use download URL consumes a capability. Starting an export can create a large bill. Reading an "invite acceptance" route can add a user to an organization. Calling a report endpoint can wake an expensive warehouse job. The resource you return might be read-only while the operation that produced it is not.
Legacy APIs hide writes in familiar places
The worst legacy routes usually began as shortcuts for a human-facing page. Someone made an administrative link easy to click, then another service copied the URL, then a script depended on it, and the shortcut became an API contract.
Password-reset confirmation links are a classic case. A route such as GET /reset/confirm?token=... looks convenient because a browser can open it. If opening that URL consumes the token and changes the password, mail scanners and preview tools can consume it first. The safe design uses GET to display a confirmation state without consuming anything, then uses POST to submit the confirmation. The page can carry a short-lived server-side reference, but the write happens only after the explicit action.
Unsubscribe links need more care, not less. Email systems and privacy laws make one-click unsubscribe attractive, and some standards expect it. If a mailbox security scanner follows such a link, the recipient can lose a subscription without touching the message. Use the standard header mechanism where it applies, understand how the receiving ecosystem handles it, and make the endpoint’s behavior intentional. Do not copy a generic "GET unsubscribe" pattern into an unrelated administrative API and call it precedent.
Other repeat offenders include:
GET /jobs/123/retry, which creates a new execution every time a dashboard refreshes.GET /deployments/123/rollback, which a monitoring probe can call while testing links.GET /tokens/123/revoke, which makes a support URL into a destructive capability.GET /invoices/123/send, which turns a preview bot into a mail sender.GET /reports/monthly, which silently starts a costly export instead of returning one.
The popular recommendation to "just require a secret query parameter" is wrong. Query strings spread into browser history, analytics, server logs, referrer headers in some flows, screenshots, and copied messages. More importantly, a secret URL is still a GET URL. Anyone or anything that receives it may activate the action without an approval boundary.
Retries and previews magnify the blast radius
A single mutating GET has a larger audience than its author expects because automated callers treat it as repeatable. The first symptom often looks random: an operation happens twice, an account changes overnight, or a user sees an action they did not perform. The request logs show legitimate credentials, so the incident gets labeled operator error. That label often ends the investigation too early.
Consider a legacy endpoint that restarts a remote build when it receives GET /builds/77/retry. An agent fetches the URL through a network path that times out after the server accepted the request. The agent does what many HTTP clients do and retries. The original request has already enqueued build 311; the second request enqueues build 312. A dashboard then loads a preview link in the activity feed and enqueues build 313. The handler may return 200 OK every time, so nothing in the response says that the operation duplicated.
A redirect can add another surprise. If an old GET action redirects to a new route and the new route still acts on GET, the redirect preserves the unsafe semantics. If the redirect changes the method in a way the client does not expect, clients can fail inconsistently. Redirects are migration aids, not a place to hide a change in authorization or method semantics.
Caches make the failure stranger. A shared cache should not store a response to a mutating GET without explicit instructions, but systems make mistakes and developers add cache headers mechanically. Even without cache storage, a prefetcher can issue the request before the user decides to act. Do not build safety around the hope that every intermediary honors your private intent.
The repair starts at the boundary. An operation that can change a remote system needs an explicit action request before the HTTP client sends it. The caller should see a distinct action name, target, and consequence. A timeout after dispatch is then an uncertain write, which the caller handles with status lookup or an idempotency key instead of a blind replay.
Give actions a write-shaped contract
A repaired endpoint should expose the change in its URI, method, request body, response, and documentation. You do not need a noun-heavy REST argument to do this well. You need a contract that stops callers from mistaking an action for a fetch.
For the build example, use a POST action endpoint and accept an idempotency key. The endpoint should return a resource that identifies the new execution, not a generic success message.
POST /v1/builds/77/retries HTTP/1.1
Idempotency-Key: 9ef8b462-97bf-4ca3-bb8b-4396a60ed9ae
Content-Type: application/json
{"reason":"retry after failed dependency download"}
HTTP/1.1 201 Created
Content-Type: application/json
Location: /v1/builds/311
{"id":"311","source_build":"77","state":"queued"}
Store the idempotency key with the authenticated principal, action type, target, and request digest. If the same caller sends the same key and same request again, return the original result. If they reuse the key with a different body or target, return a conflict. A globally shared key record can let one tenant collide with another, while a key that ignores the request body can turn a copy-and-paste mistake into the wrong action.
Use PUT or PATCH when the request describes the desired resource state. PATCH /v1/deployments/77 with {"paused":true} can make sense when the resource owns that field. POST /v1/deployments/77/rollback better describes a command with a new execution, audit trail, and possible asynchronous result. Do not force a command into PATCH merely to satisfy someone’s style guide.
Return enough state for a caller to recover from ambiguity. If an action runs asynchronously, return an operation ID and provide a GET endpoint that only reads its progress. The GET can then be retried, polled, cached according to its response headers, and opened in a browser without changing the world.
Approval belongs before credential injection
Approval after an HTTP request has left the machine is theater. A remote service can act before the client receives a response, and a failure response does not prove it did nothing. Put the decision where the request is assembled, before credentials attach and before bytes leave the process.
This matters when an AI coding agent calls an API. The agent may infer that a route is a read from a tool description, copy an old URL from a repository, or follow a suggestion in a ticket. If it has raw credentials, it can make the call before a human sees the endpoint. A prompt that asks the model to be careful is not an authorization control.
Give the approval layer a normalized action model. It should carry at least the HTTP method, host, path, target identifier when one exists, and a concise consequence. The layer should classify actions using the service contract, not just method === "GET". A legacy GET route that calls revokeToken must enter the same approval path as POST /tokens/123/revoke until you remove it.
A practical mapping can look like this:
{
"method": "GET",
"url": "https://api.example.test/v1/tokens/tk_42/revoke",
"semantic_action": "revoke credential",
"target": "tk_42",
"approval": "required",
"reason": "legacy GET endpoint changes remote credential state"
}
Do not show an approver a bare hostname and a green button. The prompt should tell them that the call revokes a credential and name the target they are about to affect. If your system cannot determine the semantic action, treat the call as unclassified and require approval. Allowlisting all GET requests because they are GET requests recreates the original flaw one layer deeper.
Sallyport’s per-session authorization and per-call credential controls can sit at this boundary for agents using its HTTP channel. The important design choice is independent of the app: the vault holder executes the request, while the agent receives the result rather than the secret.
Keep reads useful and writes hard to trigger by accident
The clean migration pattern preserves a safe GET for discovery and introduces a separate write endpoint for the act. You can keep a human-friendly page, a status endpoint, or a dry-run response without allowing a fetch to execute the command.
For a report generator, GET /reports/monthly can return the most recent completed report and the current generation status. POST /reports/monthly/runs starts a new generation. For a credential operation, GET /tokens/tk_42 can return metadata, while POST /tokens/tk_42/revocations creates a revocation event. The extra path segment is less clever than an action query parameter, but it makes logs, clients, and review screens much clearer.
A dry run deserves a precise contract. POST /deployments/77/rollback?dry_run=true is still a POST because the caller requested a command evaluation, even if it commits nothing. Return the planned targets, expected preconditions, and any unresolved values. Do not make GET /rollback?preview=true run the command planning if planning itself acquires locks, reserves capacity, or contacts a provider with an observable effect.
Some teams try to preserve old integrations by letting the old GET return an HTML page with a form that auto-submits a POST. That only moves the risk into a browser. Use a page that requires an actual user interaction and protects the POST with the same-origin defenses appropriate to the application. API clients should receive a clear deprecation response and a migration deadline, not a browser document they cannot use.
Test the callers that never ask permission
A route is not fixed until you test the automated behavior that made it unsafe. Unit tests that assert a handler calls a service method are not enough. Exercise the route as a browser, an HTTP client under a timeout, a crawler, and an agent would encounter it.
For every migrated action, run these checks in an isolated environment:
- Send the old GET twice and confirm it cannot create two actions. During a transition it should fail safely, display only a confirmation state, or return a deprecation response.
- Simulate a client that loses the response after dispatch, then retries the POST with the same idempotency key. Confirm that the service returns the original action ID.
- Fetch the safe status URL repeatedly and confirm that it creates no jobs, messages, ledger entries, or external calls.
- Attempt the action with an expired approval or revoked session and confirm that the request never reaches the remote service.
- Inspect the audit record and verify that it identifies the normalized action rather than only the transport route.
Use fault injection at the awkward point: after the server commits the action but before it sends a response. That is where teams discover whether their client retries blindly. If the only recovery strategy is "try it again," the contract has not given the caller enough information.
Also test documentation examples. A curl command copied into an incident channel becomes an operational interface. If the example uses GET because it fits on one line, someone will automate it. Make the safe read example and the explicit action example visibly different.
Audit the effect as well as the route
An audit line that says GET /v1/builds/77/retry 200 is poor evidence. It records a transport fact while hiding the business event. During an incident, the investigator still has to reconstruct whether the call started a build, retried an earlier one, or merely returned its state.
Record both layers. Preserve the received method and route because legacy behavior matters. Alongside it, record the semantic action, target, caller process or principal, approval decision, credential identity without secret material, correlation ID, and result reference. For an asynchronous command, log the operation ID or resulting resource ID so later events join to the original request.
A tamper-evident log is useful only if you can verify it after trust has become questionable. Keep the verification process separate from the application’s normal read path. Sallyport projects its session and call journals from a write-blind encrypted hash-chained audit log, and sp audit verify checks that chain offline over ciphertext. That is the sort of property worth demanding when an agent had authority to affect an external system.
Do not let audit retention become an excuse to record secrets. Request bodies often contain credentials, tokens, personal data, or command arguments that do not belong in a general activity log. Log a normalized description and a digest where you need integrity evidence. Store sensitive material only where access controls and retention rules can support it.
Remove the exception instead of documenting it forever
The final state has no mutating GET routes, even if an approval gateway currently catches them. Keeping the exception alive invites a new client, a copied URL, or a future refactor to bypass the classification table. The compatibility layer should have an owner, a caller inventory, and a date when it stops accepting the old form.
Start with the endpoint that can cause the most irreversible result. Add an explicit POST contract, idempotency behavior, approval classification, and effect-level audit record. Then make every old GET call observable. When you can name the remaining callers, move them deliberately instead of breaking a hidden integration by surprise.
Do not accept "our client knows better" as a safety property. A GET request travels through systems that were built to repeat and inspect it. Make your write look like a write before one of those systems decides to help.
FAQ
Can a GET request legally change data?
No. GET is defined as a safe HTTP method because the requested semantics should not change server state. A server can still log a request or update a cache internally, but a GET that deletes, restarts, sends, bills, or changes a record has broken the contract callers rely on.
How do I find GET endpoints with side effects?
Start with routes named retry, cancel, reset, resend, rotate, sync, export, confirm, or preview. Then compare request logs with database writes, job enqueues, outbound messages, and third-party calls made immediately after those routes run.
Should a GET endpoint that sends an email require approval?
Treat it as a write when its requested outcome changes an account, resource, workflow, permission, billing state, external system, or message delivery. The HTTP verb is evidence about intent, not proof that the operation is harmless.
Why are mutating GET requests dangerous with retries?
They can repeat a request after a timeout, follow a redirect, prefetch a link, refresh a page, or fetch a resource while building a preview. A human may believe they clicked once while the server receives two or more requests.
Is authentication enough for a state-changing GET request?
No. Authentication answers who may call an endpoint; approval answers whether this specific action should happen now. A long-lived credential attached to an agent does not make an unexpected state change acceptable.
Should I use POST, PUT, or PATCH for a legacy GET action?
Use a POST action endpoint when the operation triggers a command, such as /jobs/{id}/cancel, and document its result. Use PUT or PATCH when the caller supplies a replacement or partial representation of a resource.
Can I keep the old GET route for backward compatibility?
Keep the old route temporarily only if you can measure and migrate its callers. Make it redirect only to a non-mutating confirmation page, reject unsafe automated callers, or return a clear deprecation response while clients move to the new action endpoint.
Can idempotency keys make unsafe GET endpoints safe?
Yes, if each attempt carries an idempotency key and the service stores the first completed result for an appropriate period. That protects against duplicate delivery, but it does not make a mutating GET safe for crawlers or previews.
What should an audit log record for a mutating API request?
Log the normalized action, target, caller identity, authorization decision, result, and a correlation ID. Also record the incoming HTTP method and route so investigators can prove that an old read-shaped request performed a write.
How can an AI agent approve dangerous API calls without seeing credentials?
Put the approval boundary before the client sends the HTTP request, because the remote service may act before any response returns. Sallyport can do that when an agent uses its HTTP channel: the agent asks for an action, while the app injects the credential and records the call.