Security
Don't Trust the Client With State You Didn't Sign
Sometimes the architecture forces you to route state through an untrusted client — a redirect, a cookie, a token. When you do, the client is a courier you can't trust, so the payload has to be signed or encrypted and validated on the way back.
- Security
- Architecture
- Web
- Cryptography
There’s a recurring situation in web and network systems where you have no choice but to hand a piece of state to the client and trust it to bring it back: a redirect that carries context between systems, a session cookie, a token passed through a browser. The mistake is treating the client as a courier — someone who’ll faithfully carry your envelope unopened. The client is not a courier. The client is a party you don’t control, who can read, modify, or forge anything you hand it. If state has to travel through it, that state must be signed or encrypted so you can detect tampering when it comes back.
Sometimes the client is the only path
I first really internalized this watching how a captive portal works. A device connects, a network gateway redirects it to a sign-in page, and that page needs to know the context of the connection — which access point, which network, which device — to do its job. There’s no clean back-channel; the context has to ride along through the client’s browser in the redirect itself. The architecture genuinely requires routing trusted information through an untrusted intermediary.
That pattern is everywhere once you see it. OAuth redirects carry state through the browser. Signed cookies carry session data the server will read back. Pre-signed URLs carry authorization through whoever holds the link. In all of them, the client is structurally in the middle of state it shouldn’t be able to tamper with — and the only reason it’s safe is what you wrapped the state in.
A courier you can’t trust
The framing that fixes this is to stop imagining the client as a helpful messenger and start imagining it as an adversary who will absolutely open the envelope. If you put context in a redirect as plain, readable parameters, assume the client read them, changed them to something more advantageous, and handed you back the modified version with a straight face. Anything the client can see, it can alter; anything it can construct, it can forge.
The client isn’t carrying your state to be helpful. It’s holding your state, and it can rewrite every word before it gives it back. Plan for the rewrite.
So the question for any client-carried state is not “will the client mess with this?” — assume yes — but “if it does, will I notice?” If the answer is no, you don’t have a feature, you have a vulnerability with extra steps.
Sign it or encrypt it, then validate on the way back
The defenses are well-worn, and the captive-portal case used one of them: the connection context was packed into an encrypted blob, handed to the client in the redirect, and decrypted and validated server-side when it came back. The client carried it but couldn’t read or alter it meaningfully — tampering produces garbage on decrypt, which the server rejects.
The two tools, depending on whether the payload also needs to be hidden:
- Sign it when the contents can be visible but must be tamper-evident. Attach a signature the server verifies on return; any modification invalidates the signature. (This is the signed-cookie / signed-token model.)
- Encrypt it when the contents shouldn’t be readable by the client at all. The client holds an opaque blob, and the server decrypts and validates it. (This is the encrypted-redirect-parameter model.)
Either way, the non-negotiable second half is validation on the way back. Signing or encrypting on the way out is pointless if you don’t actually verify the signature, or check that the decrypt produced something well-formed, when it returns. The check is the whole point; the crypto just makes the check possible.
The values inside are still input
One more trap: even after you’ve verified that client-carried state wasn’t tampered with, the values inside it are still data that has to be treated as input. A signed token proves the payload is the one you issued and unmodified — it does not prove the payload is harmless to feed into a query, a path, or a command. Authenticity and safety are different properties.
So the order is: verify integrity first (is this the envelope I sealed?), then still validate the contents (are these values sane to act on?). Skipping the second step because the first passed is how a perfectly authentic token carries a perfectly valid injection payload. Tamper-evidence buys you “this is what I sent”; it does not buy you “this is safe to use.”
The rule, generalized
Pulling it together into something portable:
- If you must route state through a client, the client is untrusted. Always. No exceptions for “it’s just internal” or “who would bother.”
- Sign it if it can be seen, encrypt it if it can’t. Pick based on whether the client should be able to read the contents.
- Validate on return, every time. The protection is the verification step, not the act of wrapping it.
- Still treat the verified contents as input. Integrity is not safety; validate the values before you act on them.
This is really an applied case of two things I keep coming back to: that encryption is a threat-model question — you encrypt the redirect blob because the threat is a tampering client — and that where encryption belongs depends on what you’re defending against. Client-carried state is one of the clearest places that thinking pays off. If you’ve got a redirect or token flow you want a second look at, I’m easy to reach.