Secrets in URLs: Why Rotation Is the Second Step
A shared secret that authenticates one service to another has to travel somehow. A surprising number of systems send it as a query parameter, usually because the framework's example did.
The consequence is easy to state and easy to underestimate: a secret in a URL is written to every access log, proxy log, and trace that handles the request. Not at risk of being written — written. On a busy integration that is thousands of copies within a month, across systems that were never designed to hold credentials, some of which are retained for a year and copied into backups nightly.
When a team discovers this, the instinct is to rotate the secret. That is necessary. It is also the second step, and doing it first wastes the rotation.
Why rotation alone fails
Rotation replaces a value that may be compromised. It does nothing to the mechanism that exposed it. If the transport is unchanged, the new secret starts accumulating in the same logs the same day — and now there are two exposed credentials in the retention window instead of one.
The same reasoning applies to log redaction. Filtering the secret out of your own access log is worth doing, but it is a mitigation for a transport choice rather than a fix. It protects the one collection point you control. It does nothing about upstream proxies, tracing systems, CDN logs, browser history, referrer headers, or any component added later by someone who does not know the rule.
A header never enters the URI. Nothing downstream has to remember to redact it, which means the property holds for components that do not exist yet.
So the order is: change the transport, then rotate. Done that way, the new secret has never appeared in a URL at any point in its life, and the old one is dead before anyone has to reason about how many copies exist.
Confirm which code is actually sending it
Before patching, establish which code path is live. This sounds obvious and is skipped constantly.
Long-lived systems accumulate layers. A framework ships a default integration; a team later writes its own because the default did not fit; the default remains installed but disabled in configuration. Documentation describes the default, so that is what gets patched — correctly, with tests, through review — and it changes nothing, because the live requests come from somewhere else.
The check is cheap. Read the configuration that selects the handler, or add a log line and watch which one emits it. Both take minutes. Discovering the mistake after a release costs considerably more, and the failure is silent: a patch to dormant code produces no error, no behaviour change, and a green pipeline.
Changing a protocol without a synchronised cutover
Moving a secret from a query parameter to a header changes the contract between two systems, usually in two different repositories with independent release cycles.
The naive approach deploys both at once. Miss the window and every call is rejected. Worse, for a webhook or cache-invalidation integration, rejection is often invisible: nothing errors, content simply stops updating, and the problem surfaces days later as "the site seems stale."
The alternative removes the coupling entirely:
- Teach the receiver to accept both forms, preferring the new one. Deploy it alone. Behaviour is unchanged, because nothing is sending the new form yet.
- Switch the sender in its own release, on its own schedule.
- Remove the old form once logs confirm nothing uses it.
At no point do the two sides need to agree, and no partial deployment can break the path. The transition window costs one extra release and removes the entire class of cutover failure.
Order the steps the other way — sender first — and there is a period where the sender emits something the receiver does not understand.
Verify by using the system
Configuration-level checks are necessary and not sufficient. A deployment reporting success, a configuration status showing no differences, and a setting reading back as enabled can all be true while the feature does nothing.
That is not hypothetical. A configuration value can be written, validated against its schema, and then silently discarded on import because the property was not registered as exportable — every indicator stays healthy and the feature is inert.
The check that distinguishes working from broken is performing the operation the system exists to perform and confirming the effect. For a cache-invalidation integration, that means editing content and watching whether the front end updates.
One useful property to design for: make the failure mode closed. If a wrong header name or a mismatched secret produces a rejection rather than silence, a single successful call rules out both at once. Integrations that fail open, or fail silently, require far more evidence to trust.
Cleaning up afterwards
Once the transport is fixed and the secret rotated, the old value is worthless — but it is still sitting in retained logs. Delete those files rather than waiting for rotation to age them out, and verify afterwards that no unredacted occurrences remain anywhere.
One caveat worth planning for: if those logs were included in backups before deletion, copies persist inside backup archives until the retention window passes. That is low risk once the credential is revoked, but it is the difference between "resolved" and "resolved everywhere," and it is worth knowing which one you can claim.
The short version
- Treat a secret in a URL as already logged. The only question is how many systems hold a copy.
- Fix the transport before rotating, or you will rotate twice.
- Confirm which code path is live before patching it.
- Make the receiver accept both forms during a protocol change, so the two sides deploy independently.
- Verify by exercising the behaviour. A green deployment tells you the deployment ran.