Your Sentry DSN Is a GitHub Variable, Not a Secret

I put the DSN in secrets. Then I got called on it.

We were wiring Sentry into jo4 — @sentry/spring-boot-starter-jakarta on the alertstage backend, @sentry/react plus @sentry/vite-plugin on the grossvideo frontend. Two DSNs, one for the API project, one for the web project. I did what muscle memory tells every engineer to do with a string that looks vaguely credential-shaped:

gh secret set SENTRY_DSN_API --body "https://...@o.../12345"
gh secret set SENTRY_DSN_WEB --body "https://...@o.../67890"

Then someone with more sleep than me asked the right question: why is the DSN a secret?

I had no good answer. The honest answer was "because it looks like one." That's not a reason. That's a reflex. And it's the wrong reflex.

What a Sentry DSN Actually Is

A Sentry DSN — the "Data Source Name" — is a public identifier. Sentry's own docs say this out loud: the DSN is safe to expose. It identifies which project an event belongs to and authorizes ingestion of events into that project. Nothing else. It can't read your issues, can't delete events, can't change project settings, can't manage members.

This isn't a quirk; it's the design. In a React app the DSN ships inside the bundle the user's browser downloads. Anyone who opens DevTools can see it. There is no version of "keep the DSN secret" that survives contact with the browser — the moment that bundle hits a CDN, the value is public.

The mental shortcut I missed: if the value reaches an end-user device in plaintext, it cannot be a secret on the build side. The two states are incompatible.

Secrets Versus Variables in GitHub Actions

GitHub Actions gives you two stores for configuration: Secrets and Variables. People reach for Secrets by default because the word "secret" sounds safer. Both are encrypted at rest. The real difference is what happens at use-time:

Behavior secrets.* vars.*
Encrypted at rest Yes Yes
Masked in workflow logs Yes — replaced with *** No — printed verbatim
Available to fork PRs No No
Readable from the UI after creation No (write-only) Yes
Intended for Bearer-equivalent credentials Non-sensitive configuration

That "masked in logs" row is the one that bites you. If you put a non-secret value into secrets.* and that value happens to be a substring of any other log line, GitHub will helpfully redact it. I have spent real hours staring at logs full of *** trying to figure out what URL a build was actually hitting. The protection isn't free — it costs you observability. Only pay that cost for things that genuinely need protecting.

The "readable from the UI" row matters too. Once you set a Secret, no human (you included) can read it back through the UI. That's correct for an OAuth client_secret. It's overkill for a public identifier you could pull from view-source: on your own site.

The Rule I Use Now

I categorize every CI-stored value with one question: identifier or credential?

  • Identifier — names a resource or routes traffic to it. Anyone could discover this value by inspecting the running app or the public docs. → vars.*
  • Credential — authorizes an action. Possession of the value is sufficient to do something privileged. → secrets.*

Run the question on common candidates:

  • Sentry DSN — identifier (write-scoped to ingestion, embedded in client). → variable
  • OAuth client_id — identifier (public by spec). → variable
  • Public API endpoint URL — identifier. → variable
  • Public Stripe key (pk_live_...) — identifier (designed for browsers). → variable
  • Build-time feature flag values — configuration. → variable
  • OAuth client_secret — credential. → secret
  • Database password — credential. → secret
  • Sentry auth token — credential (see next section). → secret
  • JWT signing key — credential. → secret
  • AWS access key — credential. → secret

The DSNs moved:

gh secret delete SENTRY_DSN_API
gh secret delete SENTRY_DSN_WEB
gh variable set SENTRY_DSN_API --body "https://...@o.../12345"
gh variable set SENTRY_DSN_WEB --body "https://...@o.../67890"

And the workflow stopped referencing ${{ secrets.SENTRY_DSN_* }} in favor of ${{ vars.SENTRY_DSN_* }}. The build logs immediately got more useful because the DSN now appears in plain text wherever the build prints it.

What's Actually Secret on the Sentry Side

There is a real secret in the Sentry integration: the auth token used by @sentry/vite-plugin to upload sourcemaps during the frontend build.

That token is a different animal. It's a bearer credential, scoped to specific organization/project/release operations, that lets the holder write to Sentry's API on your behalf. If it leaks, an attacker can spam your release artifacts, delete sourcemaps, or otherwise mess with your project's metadata. It is never embedded in any user-facing artifact — it lives inside the build container, uploads sourcemaps, and is gone.

That one stays in secrets.SENTRY_AUTH_TOKEN, gets the narrowest RBAC scope Sentry will let me create (release upload only, single project), and rotates on a schedule. That's the part of the Sentry setup where the GitHub Secret machinery is actually doing work.

So: same vendor, two values, two stores. DSN is an identifier going to vars. Auth token is a credential going to secrets. The category drives the placement.

Mistakes I Avoided By Listening

The pushback came before anything shipped, so the postmortem is short. But it's worth saying what would have happened otherwise:

  • Build logs full of *** whenever I needed to debug why an event wasn't reaching Sentry. The DSN appears in plugin output, in init logs, sometimes in error traces. Masking all of it makes incident response slower for zero security gain.
  • A team norm where "everything sensitive-looking is a Secret" hardens into policy, and a year from now someone is rotating DSNs quarterly because that's what we do with Secrets. Wasted effort compounds.
  • Worst case: the category error spreads. If DSNs are Secrets, why not the public OAuth client_id? Why not the API base URL? The line gets blurry, and the actual credentials get buried in noise.

Once you concede that "looks sensitive" is sufficient reason to make something a Secret, you've lost the ability to tell the real ones apart.

DSN: variable. Auth token: secret. Identifier versus credential. That's the whole rule.


Have your own rule for secrets vs variables? What's worked and what's bitten you? Drop it in the comments.

Building jo4.io — a URL shortener with analytics for developers who ship.