---
title: GitHub Actions
path: integrations/github-actions
status: published
---

# GitHub Actions

Replace GitHub repository / organization Secrets with ScaiVault reads at job start. Lets you rotate credentials without re-editing GitHub Secrets, keep a unified audit trail (who-read-what including CI), and apply path-pattern policies that GitHub Secrets can't express.

## Authentication: OIDC, not static tokens

**Don't store a long-lived ScaiVault token as a GitHub Secret.** Use OIDC-to-ScaiKey token exchange — every workflow run gets a short-lived JWT scoped to that run.

```yaml
permissions:
  id-token: write   # required for OIDC
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4

    - name: Exchange GitHub OIDC for ScaiVault token
      id: sv-auth
      uses: scailabs/scaivault-auth-action@v1
      with:
        base-url: https://scaivault.scailabs.ai
        audience: scaivault
        role: sa:github-deployer-billing
        # Optional: narrow further per workflow
        allowed-paths: "environments/production/billing/**"
```

Behind the scenes the action:

1. Requests an OIDC token from GitHub with `aud=scaivault`.
2. Calls `POST /v1/auth/exchange` on ScaiVault with the OIDC token.
3. Exports `SCAIVAULT_TOKEN` for subsequent steps. Token TTL: 1 hour (job time).

You need to register GitHub's OIDC issuer with ScaiKey once. See the [Authentication reference](../reference/authentication#post-v1authexchange).

## Read secrets at job start

After auth, secrets are normal API calls:

```yaml
    - name: Fetch deploy credentials
      id: secrets
      run: |
        # Single secret as env vars
        stripe=$(scaivault secrets read environments/production/billing/stripe --json)
        echo "STRIPE_KEY=$(echo "$stripe" | jq -r '.data.secret_key')" >> $GITHUB_ENV

        # Mark as a secret in logs (masks output)
        echo "::add-mask::$(echo "$stripe" | jq -r '.data.secret_key')"

    - name: Deploy
      env:
        STRIPE_KEY: ${{ env.STRIPE_KEY }}
      run: ./deploy.sh
```

`::add-mask::` tells GitHub Actions to redact the value if it ever appears in logs. Always add it after reading a sensitive value.

## Better: an action that handles the masking

```yaml
    - name: Fetch secrets
      uses: scailabs/scaivault-read-action@v1
      with:
        secrets: |
          STRIPE_KEY=environments/production/billing/stripe#secret_key
          DB_PASS=environments/production/billing/database#password
```

The action reads each secret, extracts the named field, exports it as an env var, and adds it to the mask list. Behaves like `aws-actions/configure-aws-credentials` does for AWS but for ScaiVault.

## Pattern: deploy keys with policy scoping

Each repo / environment gets its own ScaiKey service account. The corresponding ScaiVault policy is narrow:

```bash
# Created via Terraform (recommended) or one-shot via API
curl -X POST https://scaivault.scailabs.ai/v1/policies \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "github-deployer-billing",
    "rules": [{
      "path_pattern": "environments/production/billing/**",
      "permissions": ["read"]
    }]
  }'
# Bind to sa:github-deployer-billing
```

When `sa:github-deployer-billing` is compromised, you rotate one binding and revoke that one identity. No one else is affected.

For the OIDC trust to be safe, **scope the trust relationship in ScaiKey** to specific GitHub repos and branches:

```yaml
# In ScaiKey (concept):
trusts:
  - issuer: https://token.actions.githubusercontent.com
    audience: scaivault
    subject_pattern: "repo:acme-org/billing:ref:refs/heads/main"
    grants_identity: "sa:github-deployer-billing"
```

Without subject scoping, any GitHub Actions workflow in your org could claim the identity.

## Pattern: dynamic credentials per deploy

For deploys that need short-lived database access (running migrations), use dynamic secrets:

```yaml
    - name: Generate DB lease
      id: lease
      run: |
        lease=$(curl -fsS -X POST \
          -H "Authorization: Bearer $SCAIVAULT_TOKEN" \
          -H "Content-Type: application/json" \
          -d '{"ttl":"15m"}' \
          "https://scaivault.scailabs.ai/v1/dynamic/engines/billing-db/creds/migrator")
        echo "DATABASE_URL=$(echo "$lease" | jq -r '.data.connection_url')" >> $GITHUB_ENV
        echo "::add-mask::$(echo "$lease" | jq -r '.data.password')"
        echo "lease_id=$(echo "$lease" | jq -r '.lease_id')" >> $GITHUB_OUTPUT

    - name: Migrate
      env:
        DATABASE_URL: ${{ env.DATABASE_URL }}
      run: ./run-migrations.sh

    - name: Revoke lease
      if: always()  # revoke even on migration failure
      run: |
        curl -fsS -X DELETE \
          -H "Authorization: Bearer $SCAIVAULT_TOKEN" \
          "https://scaivault.scailabs.ai/v1/dynamic/leases/${{ steps.lease.outputs.lease_id }}"
```

15-minute TTL covers a typical migration. The `if: always()` revoke ensures the user goes away even if the migration crashes.

## Pattern: caching for matrix workflows

Workflows with many parallel jobs hitting the same secrets can exceed rate limits. Use a single auth job that fetches once and passes secrets to downstream jobs via outputs (encrypted by GitHub Actions runtime):

```yaml
jobs:
  auth-and-fetch:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    outputs:
      secrets-bundle: ${{ steps.fetch.outputs.bundle }}
    steps:
    - uses: scailabs/scaivault-auth-action@v1
      with:
        base-url: https://scaivault.scailabs.ai
        audience: scaivault
        role: sa:github-deployer-billing
    - id: fetch
      run: |
        bundle=$(curl -fsS -X POST \
          -H "Authorization: Bearer $SCAIVAULT_TOKEN" \
          -H "Content-Type: application/json" \
          -d '{"paths":["environments/production/billing/stripe", "environments/production/billing/database"]}' \
          "https://scaivault.scailabs.ai/v1/secrets/batch")
        # Encrypt the bundle with the per-run secret-passing key
        echo "bundle=$(echo "$bundle" | gpg --symmetric --batch --yes --passphrase "$GITHUB_TOKEN" --armor)" >> $GITHUB_OUTPUT

  test:
    needs: auth-and-fetch
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    runs-on: ubuntu-latest
    steps:
    - run: |
        echo "${{ needs.auth-and-fetch.outputs.secrets-bundle }}" \
          | gpg --decrypt --batch --yes --passphrase "$GITHUB_TOKEN" \
          | jq -r '...'
```

The GPG dance is necessary because GitHub outputs aren't encrypted between jobs the same way Secrets are. For most cases, just re-authenticating per job is simpler and cheap enough.

## Audit observation

Every workflow run shows up in the ScaiVault audit log under the service account identity. Filter:

```bash
scaivault audit query --identity-id sa:github-deployer-billing --start "1 hour ago"
```

Each entry has the workflow's request ID, which you can correlate with GitHub Actions' run ID via the `X-GitHub-Run-ID` header (ScaiVault stashes it in `extra_data`).

## Common pitfalls

**Forgetting `::add-mask::`.** If you `echo` a fetched secret, GitHub Actions doesn't know it's sensitive. Logs leak. Always mask immediately after reading.

**Long-lived tokens stored as Secrets.** GitHub Secrets aren't a great place for ScaiVault credentials. OIDC fixes this. If you really can't use OIDC (self-hosted runner, weird network), at least rotate the static token quarterly and bind it to one specific role.

**Loose OIDC trust.** `subject_pattern: "repo:acme-org/*"` lets any repo in the org claim the identity. Scope to the specific repo and branch.

**Reading in plan, applying in apply.** Don't read secrets during `terraform plan` (it runs on PRs from forks too — leakage path). Read only in `terraform apply` jobs running on protected branches.

## What's next

- [Kubernetes integration](./kubernetes) — same patterns for in-cluster workloads.
- [Authentication](../reference/authentication) — token-exchange endpoint details.
- [Dynamic Postgres tutorial](../tutorials/dynamic-postgres-credentials) — the engine that makes the migrations pattern above work.
