Nobody could pay
A safety check meant to protect customers was the thing blocking them. The server had the wrong idea about its own address.
Customers pressed Pay on their invoice and got back 400 Invalid redirect URL. Not some of them. All of them. Payment was down.
The check that was doing it
When we send someone off to the payment provider, we also send a return address, so they land back on their invoice afterwards. The server checks that this address really points at us. Otherwise someone could hand us any address they like, and customers would come back to a page we do not own.
Good check. It was just answering the wrong question.
To know whether the address belongs to us, the server first has to know its own address. It thought it was:
http://portal.example.com
The browser sent:
https://portal.example.com
One is http, the other is https. Not equal, so rejected.
Why it thought that
Nginx sits in front and handles the certificate. It talks to the outside world over https, then passes the request inside over plain http. That is normal and it is not a security problem, the traffic outside is still encrypted.
But nginx is supposed to mention it, with a header that says “by the way, this person is on https”. Ours was not sending it. So Nuxt looked at the request it received, saw plain http, and concluded that was who it was.
Nothing was insecure. The server just did not know its own name.
The fix
I made the check compare the host, portal.example.com, instead of the whole address including the scheme. The host is the part that actually answers “is this us?”. The http or https in front does not change who owns the domain, and it is the part the proxy was quietly rewriting.
Adding the missing header at nginx would also have worked. I did not want the payment flow to depend on a proxy setting that someone can change later without knowing what it breaks.
What I keep from it
A check that stops the thing it is protecting is worse than no check, because now you have an outage and you still have to write the check properly.
Also: the same code ran fine on my machine and in staging. It broke in production because production has a proxy in front. Anything the server assumes about itself is worth doubting once it is behind something else.