Skip to content

Credential Rotation and Security

Automated Key Rotation

GitHub Actions Rotation Workflow

Automate private key rotation with GitHub Actions.

name: Rotate GitHub App Private Key

on:
  schedule:
    # Run quarterly on first day of quarter at 00:00 UTC
    - cron: '0 0 1 1,4,7,10 *'
  workflow_dispatch:

jobs:
  rotate-key:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Generate new private key
        id: new-key
        env:
          GH_TOKEN: ${{ secrets.ADMIN_PAT }}
          APP_SLUG: my-core-app
        run: |
          # Get app ID
          APP_ID=$(gh api /orgs/my-org/installations \
            --jq ".[] | select(.app_slug == \"$APP_SLUG\") | .app_id")

          # Generate new private key (requires admin PAT)
          RESPONSE=$(gh api -X POST /app/manifests/$APP_ID/conversions \
            -f manifest="$(cat app-manifest.json)")

          NEW_KEY=$(echo "$RESPONSE" | jq -r '.pem')
          echo "::add-mask::$NEW_KEY"
          echo "key=$NEW_KEY" >> $GITHUB_OUTPUT
          echo "app-id=$APP_ID" >> $GITHUB_OUTPUT

      - name: Update GitHub organization secret
        env:
          GH_TOKEN: ${{ secrets.ADMIN_PAT }}
          NEW_KEY: ${{ steps.new-key.outputs.key }}
          APP_ID: ${{ steps.new-key.outputs.app-id }}
        run: |
          # Update CORE_APP_PRIVATE_KEY
          gh secret set CORE_APP_PRIVATE_KEY \
            --org my-org \
            --body "$NEW_KEY"

          # Verify app ID hasn't changed
          CURRENT_APP_ID=$(gh secret list --org my-org \
            --json name,value | jq -r '.[] | select(.name == "CORE_APP_ID") | .value')

          if [ "$CURRENT_APP_ID" != "$APP_ID" ]; then
            echo "Warning: App ID changed from $CURRENT_APP_ID to $APP_ID"
            gh secret set CORE_APP_ID --org my-org --body "$APP_ID"
          fi

      - name: Notify rotation
        if: always()
        run: |
          gh api /repos/my-org/security-team/issues \
            -X POST \
            -f title="GitHub App private key rotated" \
            -f body="Private key for Core App was automatically rotated on $(date -u +%Y-%m-%d)"

Rotation Best Practices

  • Quarterly rotation - Balance security with operational overhead
  • Automated notification - Alert security team on rotation
  • Validation testing - Test new credentials before removing old ones
  • Rollback plan - Keep previous key for 24 hours in case of issues

Vault Rotation Policy

Configure automatic rotation in HashiCorp Vault.

# rotation-policy.hcl
path "secret/data/github-app" {
  capabilities = ["create", "update", "read"]
}

# Rotation policy - rotate every 90 days
rotation "github-app-key" {
  path        = "secret/data/github-app"
  interval    = "2160h" # 90 days

  rotate {
    # Custom rotation script
    command = "/opt/vault/scripts/rotate-github-app.sh"
  }
}

Rotation script (rotate-github-app.sh):

#!/bin/bash
set -e

# Generate new GitHub App private key via API
NEW_KEY=$(curl -X POST \
  -H "Authorization: Bearer $GITHUB_ADMIN_TOKEN" \
  -H "Accept: application/vnd.github+json" \
  "https://api.github.com/app/manifests/$GITHUB_APP_ID/conversions" \
  | jq -r '.pem')

# Update Vault secret
vault kv put secret/github-app \
  app_id="$GITHUB_APP_ID" \
  private_key="$NEW_KEY"

# Trigger External Secrets refresh
kubectl annotate externalsecret github-app-credentials \
  --namespace automation \
  force-sync="$(date +%s)" \
  --overwrite

AWS Secrets Manager Rotation

Configure rotation using AWS Lambda.

1. Create rotation Lambda function:

import boto3
import json
import requests
import os

def lambda_handler(event, context):
    service_client = boto3.client('secretsmanager')
    arn = event['SecretId']
    token = event['ClientRequestToken']
    step = event['Step']

    if step == "createSecret":
        # Generate new GitHub App key
        github_token = os.environ['GITHUB_ADMIN_TOKEN']
        app_id = os.environ['GITHUB_APP_ID']

        response = requests.post(
            f'https://api.github.com/app/manifests/{app_id}/conversions',
            headers={
                'Authorization': f'Bearer {github_token}',
                'Accept': 'application/vnd.github+json'
            }
        )

        new_key = response.json()['pem']
        new_secret = {
            'app_id': app_id,
            'private_key': new_key
        }

        # Store new version
        service_client.put_secret_value(
            SecretId=arn,
            ClientRequestToken=token,
            SecretString=json.dumps(new_secret),
            VersionStages=['AWSPENDING']
        )

    elif step == "setSecret":
        # Test new secret works
        current = service_client.get_secret_value(
            SecretId=arn,
            VersionId=token,
            VersionStage='AWSPENDING'
        )
        # Add validation logic here

    elif step == "testSecret":
        # Validate new key works
        pass

    elif step == "finishSecret":
        # Promote AWSPENDING to AWSCURRENT
        metadata = service_client.describe_secret(SecretId=arn)
        current_version = None
        for version, stages in metadata['VersionIdsToStages'].items():
            if 'AWSCURRENT' in stages:
                current_version = version
                break

        service_client.update_secret_version_stage(
            SecretId=arn,
            VersionStage='AWSCURRENT',
            MoveToVersionId=token,
            RemoveFromVersionId=current_version
        )

2. Configure rotation:

aws secretsmanager rotate-secret \
  --secret-id github-app-credentials \
  --rotation-lambda-arn arn:aws:lambda:us-east-1:123456789012:function:rotate-github-app \
  --rotation-rules AutomaticallyAfterDays=90

Rotation Comparison

Method Automation Complexity Downtime Rollback Best For
GitHub Actions Scheduled workflow Low Minimal (seconds) Manual GitHub-centric orgs
Vault Policy Built-in rotation Medium None Automatic Multi-platform
AWS Lambda Secrets Manager rotation Medium None Automatic AWS-native stacks
Manual None Low Minutes Manual Small teams

Security Best Practices

Secret Storage Checklist

  • [ ] Secrets stored in secure vault (GitHub Secrets, Vault, AWS Secrets Manager)
  • [ ] Secrets never committed to Git repositories
  • [ ] Secrets masked in CI/CD logs
  • [ ] Secrets scoped to minimum required repositories/environments
  • [ ] Organization secrets used for shared GitHub Apps
  • [ ] Environment protection enabled for production secrets
  • [ ] Secret rotation schedule documented and automated
  • [ ] Audit logging enabled for secret access
  • [ ] Access to secrets limited to required personnel
  • [ ] Backup/recovery procedure documented

Access Control Principles

Do

  • Principle of least privilege - Grant minimum required access
  • Environment-based scoping - Use different credentials for dev/staging/prod
  • Regular access reviews - Audit who has access quarterly
  • Automated rotation - Rotate credentials every 90 days
  • Monitoring and alerting - Track secret usage and alert on anomalies

Don't

  • Never commit secrets to Git (even private repositories)
  • Never log or expose secrets in workflow output
  • Never share secrets via unsecured channels (email, chat)
  • Never use the same credentials across environments
  • Never skip secret masking in CI/CD configurations

Compliance and Auditing

GitHub Audit Log Monitoring

Monitor organization secrets access:

# Query organization audit log for secret access
gh api /orgs/my-org/audit-log \
  --jq '.[] | select(.action == "org.update_actions_secret") | {
    timestamp: .created_at,
    actor: .actor,
    secret: .data.secret_name
  }'

Secret Usage Tracking

Track which workflows access secrets:

# Add to workflow for audit trail
- name: Log secret usage
  run: |
    echo "Workflow: ${{ github.workflow }}"
    echo "Repository: ${{ github.repository }}"
    echo "Actor: ${{ github.actor }}"
    echo "Ref: ${{ github.ref }}"
    echo "Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
  # Send to logging system for compliance

Comments