Per-pull-request preview environments run unreviewed code with real credentials on public URLs, and most teams secure them less carefully than production even though they often hold the same secrets. Review apps, deploy previews and ephemeral namespaces are now a default feature of hosting platforms and CI systems: every pull request gets its own URL, its own build and, too often, a copy of the staging secrets and a database seeded from production. This guide covers the four exposures that matter (secrets reachable by untrusted code, open preview URLs, real data in short-lived copies, and environments that never get torn down), with platform settings, a rollout plan, detection and framework mapping. It is written for platform engineers, DevSecOps teams and security architects who own CI/CD and hosting configuration.
Key takeaways
- Treat every preview build as untrusted code execution. A pull request, especially from a fork, can change build scripts, tests and dependencies, so anything the build can reach the author can reach.
- Give previews their own credentials, not staging's. Separate, low-privilege, short-lived identities per preview context remove most of the blast radius; OIDC federation beats stored keys.
- Preview URLs are public by default on many platforms. Turn on platform authentication for non-production deployments and do not rely on obscure URLs or noindex headers.
- Never seed previews from production. Use synthetic or masked fixtures; a preview database is a copy of whatever you put in it, on infrastructure with weaker controls.
- Teardown is a security control. Auto-stop timers, stop jobs and DNS clean-up prevent orphaned environments, forgotten secrets and subdomain takeover.
What makes preview environments different
A preview (or review) environment is a short-lived deployment created automatically for a branch or pull request so reviewers can see the change running. GitLab describes review apps as "a unique environment for each branch or merge request"; Netlify builds a Deploy Preview at deploy-preview-<PR number>--<site>.netlify.app whenever a pull or merge request is opened; Heroku Review Apps, Vercel preview deployments and Kubernetes namespace-per-PR patterns work the same way.
Three properties separate them from ordinary staging:
- The code is not yet reviewed: the environment exists precisely so that someone can look at unmerged changes. Any secret injected at build or run time is available to that unmerged code.
- The trigger is external: opening a pull request, sometimes from a fork by anyone on the internet, starts a build. OWASP calls this Public Poisoned Pipeline Execution (3PE) when public repositories run unreviewed pull request code in CI.
- The volume is high and the lifetime is short: dozens of environments a day, each with a URL, DNS entry, database and credentials, which makes manual governance impossible and orphaned resources likely.
The four exposures
| Exposure | How it happens | Primary control |
|---|---|---|
| Secrets reachable by untrusted code | Build or deploy step for a fork PR receives environment variables, cloud keys or a write-scoped token; the PR modifies a build script to print or send them | Withhold secrets from untrusted builds; separate privileged workflows; OIDC with narrow trust conditions |
| Open preview URLs | Preview is reachable by anyone with the link; links are posted in PR comments on public repositories, shared in chat and indexed | Platform authentication on all non-production deployments |
| Real data in a short-lived copy | Preview database restored from a production snapshot or pointed at a shared staging database with live records | Synthetic or masked seed data; no production connection strings |
| Environments that never die | Stop jobs fail or are never defined; DNS records outlive the app; credentials issued per preview never expire | Auto-stop, TTL-bound credentials, DNS lifecycle tied to the resource |
Keeping secrets away from untrusted code
The most damaging failure is a pipeline that builds an outside contributor's pull request with privileged credentials. The Travis CI secrets exposure (CVE-2021-41077) let forked pull requests read public projects' secure environment variables, and the Ultralytics PyPI compromise started with a GitHub Actions injection in a workflow that handled pull request content with elevated privileges.
GitHub Actions
- Prefer
pull_requestoverpull_request_target: GitHub's secure use reference says to avoidpull_request_targetif it is not necessary, because those workflows may have repository write access and secrets, and they must not check out untrusted code. GitHub Security Lab calls the result of breaking this rule "pwn requests". See the How to Find GitHub Workflows Exposed to pull_request_target Abuse. - Split build from deploy: an unprivileged
pull_requestworkflow builds the preview artefact; a separateworkflow_runworkflow holding deploy credentials consumes it. Security Lab warns that artefacts built from untrusted code are themselves untrusted, so validate them and never execute their contents in the privileged job. - Do not treat a label as a gate: "safe to test" labels are a temporary fix, because the author can push a new commit after approval and before the run starts.
- Set the default
GITHUB_TOKENto read-only and raise permissions per job. Note thatactions/checkoutpersists credentials to disk by default (persist-credentials: true), so later steps can read the token. - Use environments with required reviewers for the deploy job: a job cannot access environment secrets until a reviewer approves it.
- Use OIDC federation for cloud access so the preview deploy job holds no stored cloud keys, and restrict the cloud-side trust policy to the preview environment and repository, not to any branch. See the GitHub Actions OIDC Federation Hardening Guide.
- Never run fork PR previews on self-hosted runners: GitHub says self-hosted runners should almost never be used for public repositories, because they are not guaranteed to be ephemeral and can be persistently compromised. See the Self-hosted CI/CD Runners and Build Agents.
Hosting platform settings
| Platform | Setting | Secure choice |
|---|---|---|
| Netlify | Untrusted deploys policy (public repositories): Require approval (default), Build without sensitive variables, Build without restrictions | Require approval, or Build without sensitive variables with "public" values declared for the deploy-preview context. Never Build without restrictions. |
| Heroku | Review app config vars in pipeline settings; fork PR handling | Heroku does not auto-create review apps for fork PRs on public repositories; creating one manually injects every pipeline review app config var, so treat that as running untrusted code with your secrets. |
| Vercel | Environment variables per environment (Production, Preview, Development) | Separate Preview values with no production credentials; protection bypass secrets for automation held only by the test system. |
| GitLab | Dynamic environments with environment:name such as review/$CI_COMMIT_REF_SLUG | Keep production variables protected and scoped to protected branches and environments; review jobs get their own, lower-privilege variables. |
Heroku review apps also illustrate a less obvious identity: they are created with the GitHub token of the user who linked the pipeline. That personal token is a non-human dependency that should be owned and reviewed like any other integration credential.
Controlling who can reach a preview
A preview URL is a production-like copy of unreleased features, sometimes with debug settings, source maps and test accounts. Netlify's documentation is plain that anyone with a preview URL can open it unless access is restricted, and that the X-Robots-Tag: noindex header it adds does not by itself keep previews private.
- Vercel: Deployment Protection is configured per project with a method (Vercel Authentication, Password Protection, Trusted IPs, or Passport through your identity provider on Enterprise) and a scope. Standard Protection, available on all plans, protects every deployment except production domains. Team defaults can apply protection to new projects automatically. Protected Source Maps gates
.mapfiles behind authentication. - Netlify: password protection can be required for all Deploy Previews, and project visibility can be set to private on some plans.
- Self-built previews: put an identity-aware proxy or SSO-backed ingress in front of every preview host, not basic auth with a shared password in the README.
Decision guidance: SSO-backed platform authentication is the default choice because access ends when the person leaves. A shared password suits external reviewers such as agencies, but rotate it per release and never reuse it. IP allow lists help only when reviewers have stable egress addresses. Automation that must reach protected previews (end-to-end tests, visual diff tools) should use the platform's bypass mechanism with a dedicated secret, not a disabled protection setting.
Data: what goes into a preview database
Heroku's review app documentation states that copying production data to test apps is not supported because it risks data leaks or code operating on recent customer data, and recommends seeding with non-production data in a postdeploy script. That is the right default for every platform.
- Seed from fixtures or synthetic generators committed to the repository, so every preview starts from known, harmless data.
- If realistic data is essential, use a masked dataset produced by a controlled refresh process, never a raw snapshot. See the Production Data in Non-Production.
- No shared staging database: pointing previews at a shared staging database lets unreviewed migrations corrupt it and gives every preview the staging database credential.
- Third-party sandboxes only: payment, email and messaging integrations in previews should use provider test modes and test keys, so a preview cannot charge cards or email customers.
Isolation and lifecycle on Kubernetes and cloud
Teams that build their own previews usually create one Kubernetes namespace or one cloud stack per pull request. Isolation and expiry must be designed in.
- Namespace guardrails: a ResourceQuota per namespace caps CPU, memory and object counts so a pull request cannot exhaust the cluster. Pods are non-isolated by default, so apply a default-deny NetworkPolicy for ingress and egress and then allow only what the preview needs; this needs a network plugin that enforces NetworkPolicy. See the Kubernetes Namespace Onboarding Checklist.
- Separate cluster or account: run previews in a non-production cluster and cloud account, so a compromised preview workload cannot reach production service accounts, metadata endpoints or databases.
- Per-preview identities with a TTL: issue short-lived dynamic credentials (database users, cloud roles) that expire on their own even if teardown fails. See the Dynamic Secrets and Ephemeral Credentials Guide.
- Automatic stop: GitLab's
environment:on_stopruns a stop job when the merge request is merged or the branch deleted, andauto_stop_in(for example1 week) stops idle review apps. Heroku can destroy review apps after 1, 2, 5, 14 or 30 days of inactivity. Netlify previews stay live until deleted, so clean them up deliberately. - DNS lifecycle: a CNAME left pointing at a deleted preview resource is a dangling record that someone else can claim. Microsoft's guidance is to remove DNS entries as part of decommissioning and to prefer records whose lifecycle is tied to the resource (Azure DNS alias records). Heroku notes that predictable review app names can expose apps to subdomain takeover, which is why its default names are random. See the Subdomain Takeover and Dangling DNS Guide.
- Cookie scope: do not host previews under the production parent domain if production cookies are scoped to it; a hijacked or malicious preview subdomain can then read them. Use a separate registrable domain for previews.
Where identity fits
Every preview pipeline is a chain of non-human identities: the CI token, the deploy credential, the cloud role, the database user created for the preview, the platform integration token and any bypass secret used by test automation. Inventory them as one system, give each an owner, and make sure each is either federated (no stored secret) or short-lived. The privileged identity in this chain is the deploy credential, because it is the one untrusted code most wants. See the CI/CD Pipeline Identity Security Guide.
Rolling out secure previews
- Inventory: list every repository with previews enabled, the platform, the trigger (all PRs, forks, labels), the variables and credentials injected, the data source and the teardown method.
- Close the fork path first: set fork builds to require approval or to build without sensitive variables; remove
pull_request_targetworkflows that check out PR code. - Turn on access protection: apply SSO-backed protection to all non-production deployments using team defaults, so new projects inherit it. Pilot on one team for a week to find broken test automation, then fix it with bypass secrets.
- Split credentials: create preview-only identities with read or sandbox access; move cloud access to OIDC; rotate any staging secret that previews could read.
- Replace data sources: move seeding to fixtures or masked datasets; remove production and shared staging connection strings from preview contexts.
- Enforce expiry: add stop jobs, auto-stop timers, credential TTLs and DNS clean-up; run a weekly job that lists previews older than the agreed limit.
- Test and roll back: each change is a configuration toggle (policy setting, protection scope, variable scope), so record the previous value and revert per project if delivery breaks, while keeping the fork restriction in place.
Small and large organisations: a small team on a hosting platform gets most of the value from three settings: fork approval, Standard Protection or equivalent, and separate preview variables. Large organisations running their own preview infrastructure need a platform team to own the template (namespace guardrails, identity issuance, teardown) so that individual product teams cannot opt out.
Detection and measurement
- What to log: CI workflow runs triggered by fork pull requests, approvals of untrusted deploys, changes to preview protection settings and variable scopes, cloud role assumptions from preview identities, and access logs on preview hosts.
- What to alert on: a preview identity calling APIs outside its expected set, egress from preview workloads to unexpected hosts, preview credentials used after the preview was destroyed, and workflow file changes in pull requests from first-time contributors.
| Metric | Target direction |
|---|---|
| Percentage of projects with access protection on all non-production deployments | 100% |
| Repositories where fork builds receive sensitive variables | Zero |
| Preview environments older than the agreed limit (for example 14 days) | Near zero |
| Preview contexts holding stored (non-federated, non-expiring) cloud credentials | Falling to zero |
| Dangling DNS records found in scheduled zone reviews | Zero open beyond one day |
| Preview databases seeded from unmasked production data | Zero |
Framework mapping
| Framework | Reference | How it applies |
|---|---|---|
| NIST CSF 2.0 | PR.AA-05, PR.PS-01, PR.IR-01, PR.DS-01 | Least privilege for preview identities; configuration baselines for preview templates; separation of preview from production networks; protection of seeded data |
| NIST SP 800-53 Rev. 5 | AC-6, CM-3, CM-5, IA-5, SC-7, SA-11 | Least privilege, change control and access restrictions for pipeline configuration, short-lived authenticators, boundary protection, developer testing in controlled environments |
| ISO/IEC 27001:2022 Annex A | 8.31, 8.33, 8.25 | Separation of development, test and production environments; protection of test information; secure development life cycle |
| CIS Controls v8.1 | Control 16, Control 3, Control 6 | Application software security, data protection, access control management |
| MITRE ATT&CK | T1677, T1552.001, T1078.004, T1584.001 | Poisoned Pipeline Execution; credentials in files; use of cloud accounts obtained from a pipeline; attacker acquiring domains such as abandoned subdomains |
| OWASP Top 10 CI/CD Security Risks | CICD-SEC-4 | Poisoned Pipeline Execution, including the Public PPE variant |
Common failure patterns
- Staging secrets in the preview context: one environment variable set shared by staging and previews means every pull request can read staging credentials.
- Fork builds with full variables: "Build without restrictions" or equivalent settings chosen to make contributor previews work, exposing secrets to anyone who opens a PR. The Travis CI exposure shows how quickly this class of flaw is found.
- Privileged workflows consuming untrusted artefacts: a
workflow_rundeploy job that extracts and runs a PR's build output in a privileged context. - Tokens in uploaded artefacts: preview build artefacts that include
.git/configor environment files with live tokens; the ArtiPACKED research found exactly this in public projects. - Open preview URLs with test accounts: previews linked from public PR comments with seeded admin logins and debug pages enabled.
- Teardown that removes the app but not the DNS record or credentials, leaving takeover-ready subdomains and valid database users behind.
Practitioner checklist
- Set fork and outside-contributor previews to require approval or to build without sensitive variables.
- Remove
pull_request_targetworkflows that check out or run PR code; split build and deploy. - Set default
GITHUB_TOKENpermissions to read-only and gate deploy jobs with protected environments. - Give previews their own federated or short-lived identities with no production or shared staging access.
- Enable SSO-backed access protection for all non-production deployments through team defaults.
- Seed preview databases only from fixtures, synthetic data or a masked dataset.
- Apply ResourceQuota and default-deny NetworkPolicy to every preview namespace, in a non-production cluster and account.
- Configure stop jobs, auto-stop timers, credential TTLs and DNS clean-up, and report previews past their limit.
- Host previews on a separate registrable domain from production cookies.
Standards and references
- OWASP: CICD-SEC-4 Poisoned Pipeline Execution
- GitHub Docs: Secure use reference for GitHub Actions
- GitHub Security Lab: Keeping your GitHub Actions and workflows secure, preventing pwn requests
- Vercel: Deployment Protection
- Netlify: Deploy overview and untrusted deploys policy
- Heroku Dev Center: Review Apps
- GitLab Docs: Review apps
- Microsoft Learn: Prevent dangling DNS entries and avoid subdomain takeover
Related NHI Mgmt Group resources: CI/CD Security Guide · GitHub and GitHub Actions Security Guide · Heroku, Netlify and Render PaaS Security Guide · OWASP CI/CD Top 10 CICD-SEC-4 Poisoned Pipeline Execution Guide