Secure CI/CD pipeline with GitHub Actions, dark navy and teal branded graphic with plus pattern

A CI/CD pipeline is the most privileged automation most teams run. It has your cloud credentials, it can push to production, and it executes code from every pull request. That combination makes it a prime target, and a sloppy GitHub Actions setup is one of the easier ways to leak secrets or ship a supply-chain compromise. This tutorial walks through building a pipeline that is fast, useful, and hard to abuse.

We will use GitHub Actions because it is where most teams already live, but the principles carry over to GitLab CI, CircleCI, or Jenkins. The goal is a workflow you can copy, understand, and trust.

The anatomy of a safe workflow

Every GitHub Actions workflow is a YAML file in .github/workflows/. Three decisions at the top set the security tone for everything below: what triggers it, what permissions it gets, and how it handles concurrency. Start with least privilege at the workflow level:

  • permissions: contents: read as the default, granting more only to the specific jobs that need it
  • An explicit concurrency group so a new push cancels an in-flight run on the same branch
  • Narrow triggers, so the expensive deploy job runs on a tag or main, not on every draft PR

The default of handing every job write access to your repo is the single most common mistake. Flip it off and grant upward.

Step 1: Lint and test on every pull request

The first job is your fast feedback loop. It should run on pull requests, install dependencies from a lockfile, and run your linters and unit tests. Keep it under a few minutes so people actually wait for it. Cache dependencies with the built-in caching action so you are not reinstalling the world on every run.

This job needs only read access. It never touches secrets, and it should never need to. If a test requires a credential, that is a smell worth investigating before you hand a PR-triggered job access to anything sensitive.

Step 2: Pin your actions to a commit SHA

Here is the step most tutorials skip. When you write uses: some/action@v3, you are trusting that tag to never change. Tags are mutable. A compromised maintainer can repoint v3 at malicious code, and your pipeline runs it with your permissions. Pin to a full commit SHA instead:

uses: actions/checkout@a1b2c3d4...

Yes, it is less readable. Use Dependabot to keep the pins updated so you get security patches without giving up the guarantee. This one habit closes a whole class of supply-chain attacks, the same category as the repository-confusion campaigns hitting GitHub at scale right now.

Step 3: Handle secrets correctly

Secrets belong in GitHub’s encrypted store or, better, in a cloud secret manager that the pipeline reads at runtime. A few rules that prevent most leaks:

  • Never echo a secret, and never pass one as a command-line argument where it lands in process listings
  • Do not expose secrets to workflows triggered by pull_request from forks, which run untrusted code
  • Prefer short-lived cloud credentials over long-lived static keys, so a leak expires on its own

Step 4: Use OIDC instead of stored cloud keys

This is the upgrade that matters most. Instead of storing an AWS or GCP key as a secret, configure OpenID Connect so GitHub mints a short-lived token for each run and your cloud trusts it for a specific repo and branch. There are no long-lived credentials to steal, and access is scoped to exactly the workflow that needs it. Every major cloud documents the setup, and it is worth the afternoon it takes to wire up.

Step 5: Add a security scan as a gate

Before anything deploys, run static analysis and a dependency vulnerability scan as a required check. Catching a known-vulnerable package or a hardcoded secret at the PR stage is far cheaper than catching it in production. Treat the scan as a gate, not a suggestion, by marking it required in branch protection. This pairs naturally with human review, the same split we use in our code review tooling: machines catch the mechanical issues, humans judge the design.

Step 6: Deploy from a protected job

The deploy job is where you finally grant elevated permissions, and only here. Gate it behind a GitHub Environment with required reviewers for production, so a human approves before the pipeline touches live infrastructure. Scope its permissions to exactly what the deploy needs, use your OIDC role, and log the result. If a rollback is possible, wire it up now, not during your first incident.

A minimal mental model

Read by default, write only where proven necessary. Pin everything. No long-lived cloud keys. Scan before you ship, and put a human gate in front of production. A pipeline built on those five ideas is both faster to work with and dramatically harder to weaponize. To go deeper on the surrounding DevOps practice, our DevOps Coach and the full course library cover the rest, and the GitHub Actions security guides and OWASP CI/CD Top 10 are the references to keep open while you build.

Frequently asked questions

Why pin GitHub Actions to a SHA instead of a version tag?

Version tags like v3 are mutable, so a compromised action maintainer can repoint the tag at malicious code that then runs with your pipeline’s permissions. A full commit SHA is immutable. Pair SHA pinning with Dependabot so you still get security updates without sacrificing the guarantee.

What is OIDC and why use it in CI/CD?

OpenID Connect lets your pipeline request a short-lived cloud credential at runtime instead of storing a long-lived key as a secret. There is nothing static to leak, and access is scoped to a specific repo and branch, which dramatically shrinks the blast radius if a workflow is compromised.

How do I keep secrets safe in GitHub Actions?

Store them in GitHub’s encrypted secrets or a cloud secret manager, never echo or pass them on the command line, never expose them to fork-triggered pull request runs, and prefer short-lived OIDC credentials over static keys wherever your cloud supports it.