Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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.

Read secrets at job start#

After auth, secrets are normal API calls:

yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
- 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
1
2
3
4
5
6
- 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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 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
1
2
3
4
5
6
# 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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- 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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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
1
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#

Updated 2026-05-17 13:26:50 View source (.md) rev 1