Article · Apr 3, 2026
Origin validation in edge functions: the open redirect you ship by default
Edge functions that trust the Origin or Referer header for redirect URLs are open-redirect vulnerable. One allowlist helper closes the gap.
A penetration tester sent me a screenshot once. The URL bar showed a domain I had built. The page showed the attacker’s lookalike of our login form. The Network panel showed the user had been bounced through three of our edge functions before landing on the phishing site. The bug was the same in all three: the function read the Origin header from the incoming request and used it to decide where to send the response.
That bug ships by default in most edge functions I inherit. The fix is one helper file. The hardest part is convincing the team it is real.
What an open redirect actually does
Open redirect: a vulnerability where your server accepts an attacker-controlled URL and redirects the user to it. The typical vector is a query parameter or request header naming “where to go after success.” If the code echoes that value back as a redirect target without validation, the attacker controls the destination.
GET /login?return_to=https://attacker.example/phish
Your code reads return_to, does whatever it needs to (authenticate the user, set a cookie, send an email), then redirects to the parameter value. From the user’s perspective, the URL bar starts on your domain, they trust the page, the page kicks them somewhere else.
The exploit is not the redirect itself. It is the chain: phishing email links to your real domain, your real domain takes the user through a flow they recognize, your real domain redirects them to the attacker’s lookalike. Every step looks legitimate until the very last one.
Browsers do not warn for this. The URL bar updates correctly because that is what redirects do. The only moment the user could notice is when they glance at the final URL, which is usually after the page has already loaded.
Where the bug shows up in edge functions
Two patterns I see constantly.
Post-auth callback. Your auth flow finishes and redirects the user “back to wherever they came from.” The function reads Origin or Referer or a redirect_to query param to decide.
// Vulnerable
const redirectTo = req.headers.get('origin');
return Response.redirect(`${redirectTo}/welcome`);
Third-party API callback. You call an external service (Stripe, an OAuth provider, a payment gateway), the service calls you back, and you redirect the user to a page determined by a parameter in the callback URL.
// Vulnerable
const next = url.searchParams.get('return_url');
return Response.redirect(next);
A third pattern is worth a quick mention: CORS header echo. Your function reads the request Origin and writes it back as the Access-Control-Allow-Origin response header. Not technically a redirect, but the same root cause.
// Vulnerable
res.headers.set('Access-Control-Allow-Origin', req.headers.get('origin'));
CORS (Cross-Origin Resource Sharing): a browser mechanism that lets a server declare which foreign origins are allowed to read its responses. The server signals this via the
Access-Control-Allow-Originresponse header. Echoing the requestOriginback verbatim bypasses the protection entirely.
The common element: unexamined trust in a request-side string. req.headers.get('origin'), url.searchParams.get('return_url'), any body field. The attacker writes every byte of those values. Treating any of them as a redirect target without validation is the bug.

Why client-supplied headers are never safe inputs
The browser-level mental model that fails here: “the Origin header is set by the browser, and the browser blocks scripts from changing it, so it must be safe.” Both halves are true and the conclusion is still wrong.
The browser does set Origin and does block JavaScript from changing it. But the attacker is not constrained to use a browser. A single curl command sets Origin to anything:
curl -X POST https://your-app.com/auth/callback \
-H 'Origin: https://attacker.example' \
-H 'Content-Type: application/json' \
-d '{}'
The request reaches your edge function with Origin: https://attacker.example. The function trusts it. The redirect goes to the attacker.
There is no header a function can rely on for “where this request actually came from.” The only safe source is a value you wrote yourself: a session cookie set after a successful first request, a signed token issued by your own code, a hardcoded list of allowed origins.
The getValidatedOrigin() helper
The pattern that closes all the vulnerabilities above:
// _shared/getValidatedOrigin.ts
const ALLOWED_ORIGINS = (Deno.env.get('ALLOWED_REDIRECT_ORIGINS') ??
'https://app.example.com,https://example.com')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
export function getValidatedOrigin(req: Request): string | null {
const candidate = req.headers.get('origin') ?? req.headers.get('referer');
if (!candidate) return null;
let url: URL;
try {
url = new URL(candidate);
} catch {
return null;
}
// Compare against the allowlist as full origin (scheme + host + port)
const candidateOrigin = url.origin;
return ALLOWED_ORIGINS.includes(candidateOrigin) ? candidateOrigin : null;
}
It uses the built-in URL constructor to parse the candidate. String comparison on raw header values fails on encoding edge cases: an attacker can use %2F for /, URL-encode the hostname, or exploit normalization differences. The URL constructor handles all of that.
It compares the full origin (scheme + host + port), not just the hostname. https://app.example.com is a different origin than http://app.example.com or https://app.example.com:8443. Loose hostname matching has been the source of several real-world bypasses.
It returns null on no match. The calling function has to handle that explicitly, which forces a deliberate decision: use a hardcoded fallback, return a 400, or log the rejection. No silent pass-through.
Where the allowlist lives
Env vars with a hardcoded fallback.
Env vars let the allowlist differ per environment (production vs staging vs preview deploys) without code changes. A team member spinning up a new preview deploy updates the env var, not the source code.
The hardcoded fallback sets how the function behaves when the var is missing. I prefer fails-to-production for the production origin only. Staging and preview should fail closed: a missing env var on staging should allow nothing rather than silently falling back to a production domain.
The Supabase Edge Functions docs cover the CORS plumbing for edge functions in detail; the allowlist applies on top as the validation layer.
Audit and patch existing functions
For a project that already has edge functions, the audit takes about an hour.
# Find every redirect target
grep -rn "Response.redirect\|res.redirect\|location.*=.*req\." \
supabase/functions/ src/api/
For each match, trace where the redirect URL comes from. If it traces back to req.headers.get(...) or url.searchParams.get(...) without a validation step, that is the bug.
# Find every CORS echo
grep -rn "Access-Control-Allow-Origin.*req\|Access-Control-Allow-Origin.*origin" \
supabase/functions/ src/api/
Each match should be either a hardcoded value or a value from getValidatedOrigin(). Anything else is the same class of bug applied to a header instead of a redirect.
The patch is mechanical:
- const redirectTo = req.headers.get('origin');
+ const redirectTo = getValidatedOrigin(req) ?? 'https://app.example.com';
return Response.redirect(`${redirectTo}/welcome`);
One import, one variable name change, one fallback. Five-minute fix per function. The hard work is building the inventory.
Why this ships by default
Edge function boilerplate examples in Supabase docs, Cloudflare docs, and tutorials often use req.headers.get('origin') as a convenience to make CORS work locally. The starter code does the right thing for CORS preflight headers but the wrong thing for redirects. The pattern gets copy-pasted into every new function without anyone noticing the distinction.
When I take over a Supabase project, this is on the four-item audit list alongside the two-layer identity model, PostgREST upsert fragility, and the password-reset enumeration leak (the next post in this series). All four are silent. All four have one-file fixes. None of them show up in automated scans.
If you are running a Supabase or Cloudflare-backed product and want a second pair of eyes on the edge-function security surface, let’s talk. The audit runs about three hours and produces a list of fixes you can ship in an afternoon.
Frequently asked questions
What is an open redirect vulnerability?
An open redirect is when your server accepts an attacker-controlled URL and redirects the user to it. The typical setup is a query parameter or header naming "where to go after success." If your code echoes that value back as a redirect target without checking it against an allowlist, an attacker can craft a link like `your-app.com/login?next=https://attacker.com/phish` that looks legitimate because the hostname is yours. The user clicks, hits your auth flow, and lands on the attacker's lookalike page already trusting the URL bar.
Can I just check the Referer header instead?
No. The `Referer` header is set by the browser based on which page initiated the request, but an attacker controls both sides: their own page initiates the request from their server, and they can strip the Referer entirely via `` or `rel="noreferrer"` on a link. Trusting `Referer` for any security decision is the same class of bug as trusting `Origin`. The fix is the same allowlist.
Should the allowlist be in env vars or hardcoded?
Env vars, with a sensible compiled-in default. The allowlist changes across environments (production, staging, preview deploys, local development), and you do not want code review to be the gate that catches a missing staging domain. Use a comma-separated env var like `ALLOWED_REDIRECT_ORIGINS=https://app.example.com,https://staging.example.com` and parse it on function startup. Hardcode the production value as a fallback so a missing env var fails loud rather than open.
Does Supabase Auth handle this for me?
For the built-in auth flows (OAuth callback, magic-link redirect, password reset), yes. Supabase Auth validates the redirect URL against a list you configure under Authentication > URL Configuration. For your own edge functions that issue redirects (post-signup welcome, post-checkout return, anywhere you call a third-party API and bounce back), no. You write the allowlist yourself in the function code.