Vortos
Security

Secrets Management

Pluggable secrets backend — env vars by default with zero overhead, opt-in HashiCorp Vault (AppRole auth, caching, auto-renewal) and AWS SSM Parameter Store drivers.

Secrets Management

A secret is any value that must be kept confidential — database passwords, API keys, encryption master keys, signing secrets. The most common mistake is hardcoding these values in config files or environment variables that end up in source control.

Vortos provides a SecretsInterface with pluggable drivers: the default reads from $_ENV (zero overhead), and optional drivers pull secrets from HashiCorp Vault or AWS Systems Manager Parameter Store at startup, with in-process caching so secrets are not fetched on every request.

Reference: HashiCorp Vault AppRole auth | AWS SSM Parameter Store | 12-factor app — Config

Default: Zero Overhead

If you don't configure a secrets driver, EnvSecretsProvider is used. It reads $_ENV[$key] — a hash map lookup, not a network call. There is no overhead unless you opt into an external driver.

Drivers

EnvSecretsProvider (default)

Reads secrets from PHP's environment. This is the correct approach for most deployments: inject secrets via your container orchestrator (Kubernetes Secrets, ECS task definitions, Docker Compose env files) and let EnvSecretsProvider read them from $_ENV.

config/security.php
// Nothing to configure — this is the default.
// EnvSecretsProvider is the fallback when no driver is configured.

Setting secrets for the application:

.env
STRIPE_SECRET_KEY=sk_live_...
ENCRYPTION_KEY=base64encodedkeyhere
DATABASE_PASSWORD=...
// Anywhere in your code — SecretsInterface reads from $_ENV
$key = $this->secrets->get('STRIPE_SECRET_KEY');

Never Commit .env Files with Real Secrets

The .env file should only contain dev/test values. Production secrets go into your container platform's secret management (Kubernetes Secrets, AWS Secrets Manager, Vault) and are injected as environment variables at runtime.


VaultSecretsProvider (opt-in)

Reads secrets from HashiCorp Vault using the HTTP API v1. Supports both static token auth and AppRole auth (role_id + secret_id, the recommended pattern for applications).

Reference: Vault KV v2 secrets engine | AppRole auth

config/security.php
use Vortos\Security\Secrets\VaultSecretsProvider;

$config->secrets()
    ->driver(VaultSecretsProvider::class)
    ->vaultAddr($_ENV['VAULT_ADDR'] ?? 'https://vault:8200')
    ->roleId($_ENV['VAULT_ROLE_ID'] ?? '')
    ->secretId('env:VAULT_SECRET_ID')     // resolved from $_ENV at runtime
    ->cacheTtl(300)                        // cache each secret for 5 minutes
;
OptionDescription
vaultAddrVault server address
roleIdAppRole role ID (not secret — safe to hardcode or env-inject)
secretIdAppRole secret ID — use env:VAR_NAME to read from env
cacheTtlSeconds to cache each secret in-process. 0 = no cache.

Secret path format:

// KV v2 path: secret/data/myapp → field "database_password"
$password = $this->secrets->get('secret/myapp#database_password');

// KV v2 shorthand (field defaults to the key name): secret/myapp/database_password
$password = $this->secrets->get('myapp/database_password');

AppRole flow at startup:

  1. VaultSecretsProvider calls POST /v1/auth/approle/login with role_id + secret_id
  2. Vault returns a short-lived token
  3. All subsequent GET /v1/secret/data/{path} calls use that token
  4. When the token nears expiry, the provider automatically re-authenticates

Static token (simpler, useful in dev):

$config->secrets()
    ->driver(VaultSecretsProvider::class)
    ->vaultAddr('http://localhost:8200')
    ->token('env:VAULT_TOKEN') // reads from $_ENV['VAULT_TOKEN']
;

AwsSsmSecretsProvider (opt-in)

Reads secrets from AWS Systems Manager Parameter Store. SecureString parameters are automatically decrypted using the associated KMS key.

Requires: composer require aws/aws-sdk-php

config/security.php
use Vortos\Security\Secrets\AwsSsmSecretsProvider;

$config->secrets()
    ->driver(AwsSsmSecretsProvider::class)
    ->awsRegion('us-east-1')
    ->ssmPrefix('/myapp/prod/')           // all keys are prefixed with this path
    ->cacheTtl(300)
;
// Fetches /myapp/prod/stripe_secret_key from Parameter Store
$key = $this->secrets->get('stripe_secret_key');

AWS credentials are resolved in the standard AWS SDK order: environment variables, EC2 instance profile / ECS task role, ~/.aws/credentials. In ECS/EKS, attach an IAM role with ssm:GetParameter + kms:Decrypt permissions.


Using SecretsInterface

Inject SecretsInterface directly for any service that needs secrets at runtime:

use Vortos\Security\Contract\SecretsInterface;

final class StripeGateway
{
    private readonly string $apiKey;

    public function __construct(SecretsInterface $secrets)
    {
        $this->apiKey = $secrets->get('STRIPE_SECRET_KEY');
    }
}

All drivers implement SecretsInterface::get(string $key): string. Switching drivers is a one-line config change — your application code never changes.

Caching

Vault and SSM providers cache secrets in-process (a plain PHP array). This means:

  • Each secret is fetched once per process lifetime (or per TTL window)
  • No network calls on subsequent get() calls for the same key
  • In FrankenPHP worker mode, the cache lives for the worker's lifetime — workers are restarted periodically, ensuring secrets are refreshed
$config->secrets()
    ->driver(VaultSecretsProvider::class)
    ->cacheTtl(300) // 5 minutes — set to 0 to disable caching
;

Cache and Secret Rotation

If you rotate a secret in Vault/SSM, running workers will not pick up the new value until their cache TTL expires or they restart. For critical secret rotations (compromised keys), restart your FrankenPHP workers immediately after rotation. With cacheTtl(0), every call fetches fresh — but adds latency.

Verifying Secrets Are Working

Confirm a secret is readable:

// In a debug command or test
$value = $this->secrets->get('MY_SECRET_KEY');
assert($value !== '');

For Vault — confirm the token is valid:

vault token lookup
vault kv get secret/myapp

For SSM — confirm IAM permissions:

aws ssm get-parameter --name /myapp/prod/my_secret --with-decryption

Troubleshooting

EnvSecretsProvider returns empty string.

The key is not in $_ENV. PHP populates $_ENV only if variables_order in php.ini includes E. Alternatively, use getenv('KEY') — the provider tries both. Confirm with var_dump($_ENV) in a debug route.

Vault connection refused / timeout.

The vaultAddr is wrong or Vault is not reachable from your app's network. In Docker Compose, use the service name: http://vault:8200. In production, confirm your app can reach the Vault load balancer and the security group / firewall allows port 8200.

Vault AppRole login returns 400 "invalid role or secret id".

The role_id and secret_id must match and be active. AppRole secret IDs can be one-time-use or have a TTL — if the secret ID has expired, regenerate it in Vault. Confirm with vault write auth/approle/login role_id=... secret_id=... from the Vault CLI.

AWS SSM AccessDeniedException.

Your IAM role is missing ssm:GetParameter or kms:Decrypt permissions. Attach the appropriate policy to your ECS task role or EC2 instance profile. The minimum required policy:

{
    "Effect": "Allow",
    "Action": ["ssm:GetParameter", "kms:Decrypt"],
    "Resource": ["arn:aws:ssm:us-east-1:123456789:parameter/myapp/prod/*"]
}

aws/aws-sdk-php not installed.

AwsSsmSecretsProvider requires the AWS SDK. Install it: composer require aws/aws-sdk-php. If you do not need AWS SSM, use VaultSecretsProvider or EnvSecretsProvider instead.

On this page