DevOps

Secure Secret Storage and Rotation in CI/CD: Integrating HashiCorp Vault with GitHub Actions and Laravel

Ruslan Ismailov Published 14 min read
S

Introduction: Why Storing Secrets in .env Files and Environment Variables Is Dangerous

Most PHP projects still store database credentials, API keys, and tokens in .env files or CI/CD environment variables. At first glance, this seems convenient: Laravel reads .env out of the box, and GitHub Actions lets you define Secrets in repository settings. But this approach has fundamental security flaws.

  • Static nature. A secret, once issued, can live for months or years. Compromising a single token exposes your entire infrastructure.
  • No audit trail. You have no way of knowing which colleague copied a variable's value, which worker read the database password, or when.
  • Secret sprawl. Secrets end up in logs, build artifacts, and Docker images via ARG instructions.
  • Manual rotation. Changing a database password requires synchronous updates across all environments — always a risk for downtime.

The solution is centralized secret management with dynamic issuance and automatic rotation. The de facto standard in this space is HashiCorp Vault. In this article, we'll walk through integrating Vault with GitHub Actions via OIDC, and how a Laravel application retrieves DATABASE_URL, REDIS_PASSWORD, and APP_KEY directly from Vault on every deployment.

HashiCorp Vault Architecture: Dynamic Secrets, Leases, and Revocation

Vault is a secret manager with an HTTP API that supports multiple secrets engines and authentication methods. Key concepts to understand before integration:

Secrets Engines

Vault is more than a key-value store. Secrets engines can generate credentials on the fly. The most relevant ones for our use case are:

  • KV v2 — a versioned static secret store. Suitable for APP_KEY and third-party API keys.
  • Database Secrets Engine — dynamically creates temporary users in PostgreSQL, MySQL, and other databases with a limited time-to-live.
  • Transit — encrypts data without storing it in Vault (encryption-as-a-service).

Leases and Revocation

Every dynamic secret is issued with a lease — a time-to-live (TTL). When the TTL expires, Vault automatically revokes the secret (deletes the temporary PostgreSQL user, invalidates the token). The application can renew the lease via API, but only within max_ttl. This means that a leaked dynamic secret has a limited window of exposure — a fundamental advantage over static passwords.

Access Policies

Vault uses HCL policies to manage permissions. Here's an example policy for a GitHub Actions CI job:

# policy: github-actions-deploy.hcl

# Read static application secrets
path "secret/data/myapp/*" {
  capabilities = ["read"]
}

# Obtain dynamic PostgreSQL credentials
path "database/creds/myapp-deploy-role" {
  capabilities = ["read"]
}

# Renew leases
path "sys/leases/renew" {
  capabilities = ["update"]
}

Integrating Vault with GitHub Actions: OIDC Authentication Without Static Tokens

The traditional approach is to store a Vault token in GitHub Secrets. This brings you back to static secrets — only now that secret grants access to all other secrets. OIDC (OpenID Connect) solves this problem: GitHub Actions generates a short-lived JWT for each job run, Vault verifies it against GitHub's OIDC endpoint, and issues a scoped token.

Configuring JWT Auth in Vault

# Enable the JWT auth method
vault auth enable jwt

# Configure OIDC discovery via GitHub
vault write auth/jwt/config \
  oidc_discovery_url="https://token.actions.githubusercontent.com" \
  bound_issuer="https://token.actions.githubusercontent.com"

# Create a role for deploying a specific repository
vault write auth/jwt/role/github-actions-deploy \
  role_type="jwt" \
  bound_audiences="https://vault.example.com" \
  user_claim="actor" \
  bound_claims_type="glob" \
  bound_claims='{
    "sub": "repo:your-org/your-repo:environment:production"
  }' \
  policies="github-actions-deploy" \
  ttl="15m"

Note the bound_claims field: we bind the role to a specific repository and GitHub Environment. A token issued from a different repository or branch will fail validation.

GitHub Actions Workflow with Vault Secret Retrieval

name: Deploy Laravel to Production

on:
  push:
    branches: [main]

permissions:
  id-token: write   # Required for OIDC
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Import Secrets from Vault
        uses: hashicorp/vault-action@v3
        id: vault
        with:
          url: https://vault.example.com
          method: jwt
          role: github-actions-deploy
          # audience must match bound_audiences in the role
          jwtGithubAudience: https://vault.example.com
          secrets: |
            secret/data/myapp/production app_key | APP_KEY ;
            secret/data/myapp/production redis_password | REDIS_PASSWORD ;
            database/creds/myapp-deploy-role username | DB_USERNAME ;
            database/creds/myapp-deploy-role password | DB_PASSWORD

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'

      - name: Install Composer dependencies
        run: composer install --no-dev --optimize-autoloader

      - name: Run database migrations
        env:
          DB_HOST: db.internal.example.com
          DB_PORT: 5432
          DB_DATABASE: myapp_prod
          DB_USERNAME: ${{ steps.vault.outputs.DB_USERNAME }}
          DB_PASSWORD: ${{ steps.vault.outputs.DB_PASSWORD }}
          APP_KEY: ${{ steps.vault.outputs.APP_KEY }}
          REDIS_PASSWORD: ${{ steps.vault.outputs.REDIS_PASSWORD }}
        run: php artisan migrate --force

      - name: Deploy application
        # ... rsync, kubectl apply, etc.
        run: echo "Deploying..."

Once the job completes, Vault automatically revokes the dynamic PostgreSQL credentials. The temporary database user ceases to exist.

Practical Example: Laravel Fetching Secrets from Vault at Deploy Time

Let's look at how to configure a Laravel application to work with Vault secrets in a production environment. For runtime Vault access, you can use the vault-php package or make direct HTTP API calls.

Secret Structure in Vault KV v2

# Write static application secrets
vault kv put secret/myapp/production \
  app_key="base64:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=" \
  redis_password="sup3r-s3cur3-r3d1s-p4ss"

# Verify
vault kv get secret/myapp/production

Laravel Configuration: config/database.php

In the CI/CD pipeline, secrets are already passed as environment variables by the vault-action step. Laravel reads them in the standard way via env():

// config/database.php
'pgsql' => [
    'driver'   => 'pgsql',
    'host'     => env('DB_HOST', '127.0.0.1'),
    'port'     => env('DB_PORT', '5432'),
    'database' => env('DB_DATABASE', 'myapp'),
    'username' => env('DB_USERNAME'),  // dynamic user from Vault
    'password' => env('DB_PASSWORD'),  // dynamic password from Vault
    'charset'  => 'utf8',
    'sslmode'  => env('DB_SSLMODE', 'require'), // always TLS in prod
],

Redis Configuration

// config/database.php — redis section
'redis' => [
    'client' => env('REDIS_CLIENT', 'phpredis'),
    'default' => [
        'host'     => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD'),  // from Vault KV
        'port'     => env('REDIS_PORT', 6379),
        'database' => env('REDIS_DB', 0),
    ],
],

Runtime Bootstrap: Vault SDK for PHP

If your application needs to access Vault at runtime (for example, to encrypt data via the Transit engine), use the following package:

composer require vault-php/vault-php
<?php
// app/Services/VaultService.php

namespace App\Services;

use Vault\Client;
use Vault\AuthenticationStrategies\AppRoleAuthenticationStrategy;

class VaultService
{
    private Client $client;

    public function __construct()
    {
        $this->client = new Client(
            new \GuzzleHttp\Client(['base_uri' => config('vault.address')])
        );

        // AppRole auth for runtime access (not OIDC, since there's no GitHub context)
        $this->client->setAuthenticationStrategy(
            new AppRoleAuthenticationStrategy(
                config('vault.role_id'),
                config('vault.secret_id')
            )
        );
        $this->client->authenticate();
    }

    public function getSecret(string $path): array
    {
        $response = $this->client->read($path);
        return $response->getData()['data'] ?? [];
    }
}

Automatic PostgreSQL Secret Rotation via Vault Database Secrets Engine

The Database Secrets Engine is one of Vault's most powerful features. Instead of a single application user in PostgreSQL, Vault creates a temporary user with unique credentials for each request.

Configuring the Database Secrets Engine

# Enable the engine
vault secrets enable database

# Configure the PostgreSQL connection
vault write database/config/myapp-postgres \
  plugin_name=postgresql-database-plugin \
  allowed_roles="myapp-deploy-role,myapp-app-role" \
  connection_url="postgresql://{{username}}:{{password}}@db.internal.example.com:5432/myapp_prod?sslmode=require" \
  username="vault_root_user" \
  password="vault_root_password"

# Create a deploy role (short TTL — for migrations only)
vault write database/roles/myapp-deploy-role \
  db_name=myapp-postgres \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
                       GRANT ALL PRIVILEGES ON DATABASE myapp_prod TO \"{{name}}\"; \
                       GRANT ALL ON SCHEMA public TO \"{{name}}\";" \
  revocation_statements="DROP ROLE IF EXISTS \"{{name}}\";" \
  default_ttl="15m" \
  max_ttl="30m"

# Create an application role (longer TTL, restricted permissions)
vault write database/roles/myapp-app-role \
  db_name=myapp-postgres \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
                       GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO \"{{name}}\"; \
                       GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO \"{{name}}\";" \
  revocation_statements="DROP ROLE IF EXISTS \"{{name}}\";" \
  default_ttl="1h" \
  max_ttl="4h"

Testing Dynamic Credential Issuance

# Request dynamic credentials
vault read database/creds/myapp-deploy-role

# Response:
# Key                Value
# ---                -----
# lease_id           database/creds/myapp-deploy-role/AbCdEf123...
# lease_duration     15m
# lease_renewable    true
# password           A1b2C3d4E5f6-unique-per-request
# username           v-github-myapp-QrStUv

Each credential request returns a unique username/password pair. After the lease_duration expires, the user is automatically removed from PostgreSQL via the revocation statement.

Monitoring and Auditing: Who Requested Secrets and When

Vault supports multiple audit device types. Enabling an audit log is a mandatory step for production environments.

Configuring the File Audit Device

# Enable audit logging to a file
vault audit enable file file_path=/var/log/vault/audit.log

# Verify
vault audit list

Every operation is recorded in JSON format: timestamp, authentication method, path, result (success/deny), and client IP address. Sample log entry:

{
  "time": "2024-11-15T14:23:01.123Z",
  "type": "response",
  "auth": {
    "client_token": "hmac-sha256:...",
    "accessor": "hmac-sha256:...",
    "display_name": "jwt-github-actions",
    "policies": ["default", "github-actions-deploy"],
    "metadata": {
      "actor": "john-doe",
      "repository": "your-org/your-repo",
      "workflow": "Deploy Laravel to Production"
    }
  },
  "request": {
    "operation": "read",
    "path": "database/creds/myapp-deploy-role"
  },
  "response": {
    "data": {"username": "hmac-sha256:...", "password": "hmac-sha256:..."}
  }
}

Note that Vault never writes secrets to the audit log in plaintext — only HMACs. However, metadata (who requested what and when) is logged in full. This allows you to integrate logs with SIEM systems (Splunk, Elasticsearch) for anomaly alerting.

Metrics via Prometheus

Vault exports metrics in Prometheus format at /v1/sys/metrics. Key metrics to monitor:

  • vault_core_active — node activity
  • vault_secret_kv_count — number of secrets
  • vault_token_count — number of active tokens
  • vault_audit_log_response_failure — audit failures (if the audit device is unavailable, Vault blocks all requests)

Common Mistakes and Best Practices

The Most Frequent Mistakes

  • Storing VAULT_TOKEN in GitHub Secrets. This takes you right back to static secrets. Use OIDC instead.
  • Overly broad policies. A policy like path "*" { capabilities = ["read"] } in production is a serious vulnerability. Apply the principle of least privilege: each job should only have access to the paths it needs.
  • Ignoring max_ttl. Without a max_ttl constraint, an application can renew leases indefinitely, effectively turning a dynamic secret into a static one.
  • Running Vault without HA in production. A single-node Vault is a single point of failure. Use Raft Integrated Storage with at least three nodes, or a Consul backend.
  • Disabled audit log. Running Vault without an audit device means losing the entire access trail. This is critical for compliance (SOC 2, PCI DSS).
  • Docker images with secrets baked into layers. Never pass secrets via ARG in a Dockerfile — they persist in the image history. Pass them via runtime environment variables instead.

Best Practices

  • Use GitHub Environments with required reviewers for production deployments. The OIDC sub claim contains the environment name — bind your Vault roles to it.
  • Set up a Vault Agent Sidecar for Kubernetes deployments: the agent automatically refreshes secrets in the pod's filesystem and maintains leases.
  • Version your secrets using KV v2. In the event of an incident, you can roll back to a previous version and review the change history.
  • Separate secrets by environment: secret/myapp/staging/*, secret/myapp/production/*. Use different policies and roles for each.
  • Use Laravel's config:cache with caution: the cached config locks in secret values at the time of caching. When secrets rotate, the cache must be invalidated.
  • Regularly run vault operator key-status and rotate Vault's own encryption keys.

Conclusion

Secure PHP application deployment in 2024 is impossible without centralized secret management. The combination of HashiCorp Vault + GitHub Actions OIDC + Laravel gives you:

  • Zero static tokens in CI/CD — every job authenticates via a short-lived JWT.
  • Dynamic credentials for PostgreSQL and other databases — a compromised secret is limited to the lease duration.
  • A complete audit trail — you know exactly which workflow, run by which GitHub user, requested which secret and when.
  • Automatic rotation without downtime — Vault creates a new user before revoking the old one.

Implementing this setup requires an upfront investment in Vault infrastructure (HA cluster, policy configuration, integration with existing systems), but it pays off through reduced risk and simplified operations. PostgreSQL password rotation — which previously required cross-team coordination and risky downtime — becomes an automated background process.

Start small: deploy Vault in Docker for local testing, migrate one non-critical secret via OIDC in a staging pipeline, and verify that audit logs are working correctly. Then scale the approach across all environments and services.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →