My last post was about what this site’s deploy role is allowed to do. This one is about a different question: which code gets to use it.
My Gemfile names five gems. The lockfile resolves to 37. A few of them ship native code: sass-embedded bundles a prebuilt Dart Sass binary, and google-protobuf and ffi come with compiled extensions. Every one of those runs during bundle exec jekyll build, and I didn’t write any of them. Until recently, all of that ran in the same job that held AWS credentials.
Why that matters
Software supply chain attacks are not a thought experiment. In 2019, a hijacked maintainer account was used to publish backdoored versions of the rest-client gem. In March 2025, the popular tj-actions/changed-files GitHub Action was compromised, and its version tags were repointed at a commit that dumped CI secrets into workflow logs. Repositories that ran it during that window executed the malicious code without changing a line of their own, because they referenced the action by a tag that someone else could move.
The thing to understand about GitHub Actions is that a job is the unit of trust. When a job has id-token: write, the runner exposes the endpoint and request token for minting an OIDC token to every step in that job. Any code running in any step, including a gem’s install hook or a Sass compiler binary, can request a token and assume whatever role the trust policy allows. It doesn’t need to steal a secret. It just needs to ask.
So the question isn’t “do I trust Jekyll?” It’s “do I trust the 37 gems, their maintainers, and every account that can publish a new version of any of them, with write access to my AWS account?” Put that way, the answer is obviously no.
Two jobs, two levels of trust
The workflow now splits into a job that runs untrusted code and has no credentials, and a job that has credentials and runs no repo code:
permissions: {}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@<full-sha> # v6.0.3
with:
persist-credentials: false
- uses: ruby/setup-ruby@<full-sha> # v1.312.0
with:
ruby-version: '3.3'
bundler-cache: true
- name: Audit dependencies for known CVEs
run: |
gem install bundler-audit --no-document
bundle-audit check --update
- run: bundle exec jekyll build
- uses: actions/upload-artifact@<full-sha> # v7.0.1
with:
name: site
path: _site
retention-days: 1
deploy:
needs: build
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/download-artifact@<full-sha> # v8.0.1
with:
name: site
path: _site
- uses: aws-actions/configure-aws-credentials@<full-sha> # v6.2.0
with:
role-to-assume: arn:aws:iam::ACCOUNT_ID:role/GitHub-S3-CloudFront-Deploy
aws-region: us-east-1
- run: aws s3 sync ./_site s3://cloud405 --delete
- run: aws cloudfront create-invalidation --distribution-id EXAMPLE123 --paths "/*"
A few details carry most of the weight:
permissions: {}at the top. The workflow’s defaultGITHUB_TOKENgets nothing, and each job asks for exactly what it needs. The build job can read the repo. The deploy job can mint an OIDC token. Neither can do the other’s job.persist-credentials: falseon checkout. By default,actions/checkoutleaves the job’s token in.git/configso later steps can push. Nothing in this build pushes, so nothing should be able to.- The deploy job never checks out the repo. It downloads a directory of static files, configures credentials, and runs two AWS CLI commands. No Gemfile, no plugins, no Sass. The only third-party code that runs next to the credentials is two actions from GitHub and AWS, pinned to exact commits.
The result is the sentence I wrote as a comment at the top of the build job: a compromised gem can change the built pages, but it cannot reach AWS.
Pin actions to commits, not tags
A tag like v4 is a pointer, and whoever controls the repo can move it. That is exactly how the tj-actions compromise spread. A full commit SHA can’t be moved; if the code changes, the SHA changes.
Pinning by SHA does cost you readability and easy upgrades, so I keep the version in a trailing comment and let Dependabot handle updates. Dependabot understands SHA-pinned actions, bumps the hash, and updates the comment, so an upgrade shows up as a reviewable pull request instead of something that happens silently on the next run.
Keep the dependencies themselves current
Splitting jobs contains credential theft. It doesn’t make vulnerable dependencies less vulnerable, so the build also checks them.
bundle-audit check --update pulls the latest Ruby advisory database and compares it to Gemfile.lock. If anything in the lockfile has a known CVE, the step fails and nothing deploys. That’s a vulnerability scan with a hard gate, and it’s cheap enough to run on every push.
The bigger cleanup came first. This site used to depend on the github-pages meta-gem, which pins the whole toolchain to the versions GitHub Pages supports: Jekyll 3.9 and older releases of libraries like nokogiri and rexml. The site doesn’t even deploy to GitHub Pages, so I was carrying stale, vulnerable dependencies for no benefit. Moving to Jekyll 4 directly, plus an explicit floor of rexml >= 3.3.9 to pick up a series of denial-of-service fixes, got the lockfile current. The audit is what keeps it that way.
What this doesn’t fix
It’s worth being honest about the boundary. The split protects confidentiality of the AWS credentials. It does nothing for the integrity of the site. A malicious gem in the build job can’t touch AWS, but it can inject a script into every page it renders, and the deploy job will faithfully ship that artifact to production. That’s still a real attack, just a smaller one.
The mitigations for that are different controls, layered:
- A committed lockfile, so versions only change in a reviewed commit.
- The audit gate above for known vulnerabilities.
- A one-day retention on the build artifact, so there’s no stale bundle sitting around to be reused.
- Browser-side controls like a Content Security Policy, which limit what injected script could do. That’s the subject of the next post.
There’s also a concurrency group with cancel-in-progress: true, so if two pushes land close together, the older run is cancelled rather than finishing last and deploying stale content over the newer build.
If you want to do this to your own pipelines
- Set
permissions: {}at the workflow level and grant per job. You’ll quickly see which jobs actually need what. - Find every job that has both
id-token: write(or long-lived cloud secrets) and runs dependency code. Those are the ones to split. - Pass build output between jobs as an artifact, and keep the credentialed job down to download, authenticate, and deploy.
- Pin third-party actions to full commit SHAs and turn on Dependabot for the
github-actionsecosystem. - Add your language’s dependency audit as a failing step before the build.
None of this required a new tool or a paid product. It’s the same idea as least privilege on the IAM side, applied to code instead of identities: the thing that runs strangers’ code shouldn’t hold the keys.