On May 17, a security review put one humiliating string at the center of our portal: ?k=<token>.

That parameter carried the portal’s master key. Every time someone opened a shared document link, the key could land in browser history, web-server logs, and the logs of any proxy between the browser and the app. Nothing had to be actively compromised for that to be bad. We were writing the secret into systems built to retain request metadata.

The finding was tagged Vector 11. It was embarrassingly simple, which is exactly why I had missed it. The portal worked. A recipient clicked a link, the document opened, and internal navigation kept working. I had treated the query parameter as a practical bootstrap mechanism. The audit forced me to look at the whole request path instead of just the successful screen.

The convenient route

The original flow had one moving part that was too convenient. A link arrived with ?k=<token>, the client read the value, and the server accepted it. That made a first visit frictionless, and it made share links self-contained. It also meant the credential was part of the URL at the point where the browser, server, and intermediary infrastructure first saw it.

The diagnostic path was not a mysterious production error. The review followed the key from the address bar outward. First, the browser retained the full URL in history. Then the request reached the server, where standard request logging could record it. An intermediate proxy could do the same. The secret was valid, and that was the failure mode. A valid secret in the wrong transport becomes an observability artifact.

I had mentally grouped the query parameter with an ordinary filter or document ID. It was neither. It was an authentication credential. That distinction should have been obvious, but the shape of the URL hid it in plain sight.

A new order of trust

We changed the server to extract an access key in a strict priority order:

1. Authorization: Bearer <token>
2. a host-only portal cookie
3. ?k=<token> query parameter, legacy only

The query path remains temporarily, but it is last. Its use fires a one-time legacy-auth log event so we can identify traffic that still depends on it. The important shift is that the server does not merely accept a header now. It prefers the header over every older place the key might appear.

That logic runs in both the document-view route and the typed RPC context. The downstream document-read and share-mint operations take the access key from that trusted request context before considering legacy request inputs. Those inputs still validate during the deprecation window, because breaking active links without a migration path would create a different operational problem. The context is now the source that wins.

That sequencing mattered. It is easy to move the client to a bearer header and leave one server path reading an input field first. Then the migration looks complete from the happy path while a less common endpoint preserves the old behavior. We wired the same precedence through the route and RPC boundary before treating the client change as finished.

Cleaning the browser side

The client change is equally small and more consequential than it looks. The RPC HTTP batch link now attaches Authorization: Bearer <token> from local storage. On an initial landing where the token arrives in ?k=, the client can bootstrap from it, then remove the parameter with history.replaceState before the user refreshes or follows an internal link.

We also removed the internal navigation helper that had kept adding the key back onto URLs. Without that change, the landing page would have looked clean while every click recreated the leak. A fix that only changes the first render is cosmetic. The address bar, refresh behavior, and onward navigation all had to converge on header authentication.

I considered three easier options. Leaving ?k= in place and promising tighter logs lost immediately, because the browser history problem remains even if we tune server retention. Moving only to a cookie lost as a complete solution, because the first shared-link visit still needed to hand the recipient a credential and the existing client flow already supported header authentication. Disabling share URLs entirely would remove the exposure, but it would also remove the channel recipients use to open a shared document.

So we kept one narrow exception. Newly emitted share URLs still include ?k= because a recipient has no other channel at that first handoff. That is not a claim that query authentication is suddenly safe. It is an explicit compatibility boundary. Once the page lands, the client moves the key into the normal authentication flow, removes it from the address bar, and every refresh and internal request uses the bearer header instead.

What I had optimized for

I did not miss this because the mechanism was complicated. I missed it because I was optimizing for a simple share link and evaluating success as, “Does the recipient get the document?” The security review evaluated the same mechanism as, “Where does this credential get copied before the application reads it?”

Those are different questions, and we need both in a shared system. Our code change touched six files, added 131 lines, and removed 36. Most of that work was not cryptography. It was consistency work: request precedence, typed context, legacy validation, client bootstrap, and navigation behavior.

The lesson I am taking from Vector 11 is not that query parameters are always forbidden. It is that a secret cannot be treated like ordinary URL state just because it makes a link convenient. If a credential must cross that first boundary, the code needs a defined moment when it stops being URL data. Ours did not have one. Now it does.