Self-Hosted Sentry - force update "Allowed Domains" for JS project
My browser SDK stopped reporting. Every envelope came back 403:
{"detail": "event submission rejected with_reason: Cors"}
Server-side events kept arriving normally, the project's Allowed Domains was set to new-domain.com, and restarting Relay changed nothing.
That combination means the project config Relay is holding is not the one the settings page shows. Between them sit a Redis cache and a worker process.
Reproducing it without a browser
A malformed body is rejected at parse time, before the origin check, so garbage always returns 400 invalid event envelope and tells you nothing. You need a valid envelope — a client_report item passes every check and stores no event:
U='https://sentry.example.com/api/3/envelope/?sentry_version=7&sentry_key=<public_key>'
BODY=$(printf '{"dsn":"https://<public_key>@sentry.example.com/3"}\n{"type":"client_report"}\n{"timestamp":"2026-08-11T00:00:00Z","discarded_events":[]}\n')
for O in "https://new-domain.com" ""; do
echo "--- Origin: ${O:-<none>} ---"
[ -n "$O" ] && H=(-H "Origin: $O") || H=()
curl -s -o /dev/stdout -w '\n%{http_code}\n' -X POST "$U" "${H[@]}" \
-H 'Content-Type: text/plain;charset=UTF-8' --data-binary "$BODY"
done
https://new-domain.com → 403 Cors, no Origin header → 200. Relay always accepts a request without an origin, so this confirms the allowlist it holds does not contain my domain.
Finding where the truth diverges
Sentry stores each project config zstd-compressed under relayconfig:<public_key> in Redis with a 2h20m TTL; a background task rewrites it on change, and Relay fetches it. Compare what Sentry would build now against what is cached:
# TTL near 8400 means the cache was rewritten very recently
docker compose exec -T redis redis-cli ttl relayconfig:<public_key>
docker compose exec -T web sentry django shell <<'PY'
from sentry.models.projectkey import ProjectKey
from sentry.relay.config import get_project_config
from sentry.relay import projectconfig_cache
k = ProjectKey.objects.get(public_key="<public_key>")
print("origins:", k.project.get_option("sentry:origins"))
print("fresh build:", get_project_config(k.project).to_dict()["config"].get("allowedDomains"))
cache = getattr(projectconfig_cache, "backend", projectconfig_cache)
print("cached:", (cache.get(k.public_key) or {}).get("config", {}).get("allowedDomains"))
PY
7907
origins: ['new-domain.com']
fresh build: ['new-domain.com']
cached: ['https://old-domain.com']
The cache held the domain I had renamed away from. And 7907 of 8400 means it had been rewritten eight minutes earlier, by an invalidation I triggered by hand — so invalidation was working, and the worker was writing the old value on every rebuild.
The cause
get_project_config reads project.get_option("sentry:origins"), which Sentry caches in Django's cache. The web process saved the new value and dropped its own entry; the worker process — which rebuilds relay configs — kept its stale copy. That is why the shell (running in web) and the cache (written by the worker) disagreed, and why restarting Relay was useless: it kept re-fetching a config that was regenerated wrong.
The fix
Restart the worker so it drops the stale option, then invalidate again:
docker compose restart taskworker
sleep 15
echo "from sentry.tasks.relay import schedule_invalidate_project_config as s; s(project_id=3, trigger='manual')" \
| docker compose exec -T web sentry django shell
sleep 10
docker compose exec -T web sentry django shell <<'PY'
from sentry.relay import projectconfig_cache
cache = getattr(projectconfig_cache, "backend", projectconfig_cache)
print("cached:", (cache.get("<public_key>") or {}).get("config", {}).get("allowedDomains"))
PY
Once the cache printed the right domain, the curl probe returned 200 with an Origin header and the browser SDK started reporting again. Relay refetches on its own.
Worth checking afterwards
echo "from django.conf import settings; print(settings.CACHES)" | docker compose exec -T web sentry django shell
If that reports LocMemCache, the cache is per-process and cross-process invalidation cannot work at all — every container holds its own view of every project option, so this will recur on the next settings change. Pointing CACHES['default'] at the Redis service in sentry.conf.py is the durable fix.