In the previous tutorial, you learned how to secure APIs with rate limiting and input validation. But the best API security means nothing if your secrets are hardcoded in source code. In this article, you will learn how to manage secrets properly — from .env files to production-grade vaults.
The Problem: Secrets in Source Code
Secrets are things like database passwords, API keys, JWT signing keys, and encryption keys. When developers hardcode them, bad things happen.
In 2022, GitHub reported that over 10 million secrets were detected in public repositories in a single year. These include AWS keys, Stripe keys, database passwords, and private signing keys.
Real examples of what goes wrong:
- Uber (2016) — hardcoded AWS credentials in a GitHub repo. Attackers accessed data of 57 million users.
- Samsung (2022) — leaked signing keys, encryption keys, and source code through a misconfigured repo.
- CircleCI (2023) — compromised secrets forced every customer to rotate all stored credentials.
The rule is simple: never put secrets in source code.
Level 1: .env Files and .gitignore
The most basic approach is storing secrets in a .env file that is not committed to version control.
Creating a .env File
# .env — NEVER commit this file
DATABASE_URL=postgres://user:password@localhost:5432/myapp
JWT_SECRET=your-256-bit-secret-key-here
API_KEY=sk_live_abc123def456
SMTP_PASSWORD=email-password-here
Add .env to .gitignore
# .gitignore
.env
.env.local
.env.production
*.key
*.pem
Loading .env in Go
package main
import (
"fmt"
"log"
"os"
"github.com/joho/godotenv"
)
func main() {
// Load .env file (only in development)
if os.Getenv("ENV") != "production" {
if err := godotenv.Load(); err != nil {
log.Println("No .env file found")
}
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
log.Fatal("DATABASE_URL is required")
}
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
log.Fatal("JWT_SECRET is required")
}
fmt.Println("Secrets loaded successfully")
}
Loading .env in Python
# Install: pip install python-dotenv
import os
from dotenv import load_dotenv
# Load .env file
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL")
JWT_SECRET = os.getenv("JWT_SECRET")
API_KEY = os.getenv("API_KEY")
if not DATABASE_URL:
raise ValueError("DATABASE_URL is required")
if not JWT_SECRET:
raise ValueError("JWT_SECRET is required")
Loading .env in JavaScript (Node.js)
// Install: npm install dotenv
import "dotenv/config";
const DATABASE_URL = process.env.DATABASE_URL;
const JWT_SECRET = process.env.JWT_SECRET;
const API_KEY = process.env.API_KEY;
if (!DATABASE_URL) throw new Error("DATABASE_URL is required");
if (!JWT_SECRET) throw new Error("JWT_SECRET is required");
.env File Rules
- Never commit
.envfiles — add to.gitignorebefore your first commit - Create a
.env.example— commit this with placeholder values so other developers know which variables are needed - Different
.envper environment —.env.development,.env.staging,.env.production - Do not use
.envin production — use proper environment variables or a secret manager
Level 2: CI/CD Secrets
In CI/CD pipelines (GitHub Actions, GitLab CI, Jenkins), secrets are stored as encrypted environment variables.
GitHub Actions Secrets
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
run: |
echo "Building with secrets..."
go build -o app .
- name: Deploy
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
echo "$DEPLOY_SSH_KEY" > key.pem
chmod 600 key.pem
scp -i key.pem app user@server:/app/
rm key.pem
GitHub Actions secrets are encrypted at rest and masked in logs. But they have limitations:
- Anyone with repository write access can use them in a workflow
- Fork PRs cannot access secrets (by design — prevents secret theft)
- No audit trail for who accessed which secret
GitLab CI Variables
# .gitlab-ci.yml
deploy:
stage: deploy
script:
- echo "Deploying with $DATABASE_URL"
variables:
DATABASE_URL: $DATABASE_URL # Set in GitLab UI under Settings > CI/CD
Level 3: Secret Managers
For production applications, use a dedicated secret manager. These provide encryption, access control, audit logging, and automatic rotation.
HashiCorp Vault
Vault is the most widely used open-source secret manager. It stores secrets encrypted, controls access with policies, and provides audit logs.
# Start Vault in development mode (NOT for production)
vault server -dev
# Store a secret
vault kv put secret/myapp/database \
url="postgres://user:password@db:5432/myapp" \
password="super-secret-password"
# Read a secret
vault kv get secret/myapp/database
Reading Vault Secrets in Go
package main
import (
"fmt"
"log"
vault "github.com/hashicorp/vault/api"
)
func main() {
// Create Vault client
config := vault.DefaultConfig()
config.Address = "http://127.0.0.1:8200"
client, err := vault.NewClient(config)
if err != nil {
log.Fatal(err)
}
// Authenticate with Vault
client.SetToken("your-vault-token")
// Read secret
secret, err := client.Logical().Read("secret/data/myapp/database")
if err != nil {
log.Fatal(err)
}
data := secret.Data["data"].(map[string]interface{})
dbURL := data["url"].(string)
fmt.Println("Database URL:", dbURL)
}
Reading Vault Secrets in Python
# Install: pip install hvac
import hvac
client = hvac.Client(url="http://127.0.0.1:8200", token="your-vault-token")
# Read secret
secret = client.secrets.kv.v2.read_secret_version(
path="myapp/database",
mount_point="secret"
)
db_url = secret["data"]["data"]["url"]
db_password = secret["data"]["data"]["password"]
print(f"Database URL: {db_url}")
Cloud Secret Managers
If you use a cloud provider, they offer managed secret services:
| Provider | Service | Pricing |
|---|---|---|
| AWS | Secrets Manager | $0.40/secret/month + $0.05 per 10K API calls |
| Google Cloud | Secret Manager | $0.06/secret version/month + $0.03 per 10K access |
| Azure | Key Vault | $0.03 per 10K operations |
# AWS Secrets Manager (Python)
import boto3
import json
client = boto3.client("secretsmanager", region_name="eu-central-1")
response = client.get_secret_value(SecretId="myapp/database")
secrets = json.loads(response["SecretString"])
db_url = secrets["url"]
db_password = secrets["password"]
// Google Cloud Secret Manager (Node.js)
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
const client = new SecretManagerServiceClient();
async function getSecret(name) {
const [version] = await client.accessSecretVersion({
name: `projects/my-project/secrets/${name}/versions/latest`,
});
return version.payload.data.toString("utf8");
}
const dbUrl = await getSecret("database-url");
Level 4: Dynamic Secrets
Static secrets are dangerous because they live forever unless rotated. Dynamic secrets are generated on demand and expire automatically.
Vault can generate dynamic database credentials:
# Configure Vault to generate temporary Postgres credentials
vault write database/config/myapp \
plugin_name=postgresql-database-plugin \
connection_url="postgresql://{{username}}:{{password}}@db:5432/myapp" \
allowed_roles="readonly" \
username="vault_admin" \
password="vault_admin_password"
vault write database/roles/readonly \
db_name=myapp \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
default_ttl="1h" \
max_ttl="24h"
# Generate temporary credentials (valid for 1 hour)
vault read database/creds/readonly
# Key Value
# lease_id database/creds/readonly/abc123
# username v-token-readonly-x7k9m2
# password A1-z9k8m7n6p5q4
These credentials expire automatically. Even if stolen, they stop working after an hour.
Pre-Commit Hooks: Catch Secrets Before They Are Committed
The best defense is preventing secrets from entering version control in the first place.
Using gitleaks
# Install gitleaks
brew install gitleaks # macOS
# or download from https://github.com/gitleaks/gitleaks
# Scan your repository
gitleaks detect --source . --verbose
# Set up as a pre-commit hook
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
Using detect-secrets
# Install
pip install detect-secrets
# Create a baseline of known secrets (false positives)
detect-secrets scan > .secrets.baseline
# Set up as a pre-commit hook
# .pre-commit-config.yaml
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: ["--baseline", ".secrets.baseline"]
GitHub Secret Scanning
GitHub automatically scans public repositories for known secret patterns (AWS keys, Stripe keys, etc.) and notifies the secret provider. Enable push protection to block commits that contain secrets:
- Go to repository Settings > Code security and analysis
- Enable Secret scanning
- Enable Push protection
Secret Rotation
Even with proper secret management, you should rotate secrets regularly. If a secret is compromised, rotation limits the window of exposure.
Rotation Schedule
| Secret Type | Rotation Frequency | Notes |
|---|---|---|
| Database passwords | Every 90 days | Use dynamic secrets if possible |
| API keys | Every 90 days | Issue new key before revoking old |
| JWT signing keys | Every 6 months | Support multiple active keys during rotation |
| Encryption keys | Annually | Re-encrypt data with new key |
| SSH keys | Annually | Deploy new key, then remove old |
Zero-Downtime Key Rotation
When rotating JWT signing keys, you need both the old and new keys to be valid during the transition:
// Support multiple signing keys during rotation
type KeySet struct {
Current *rsa.PrivateKey // Sign new tokens with this
Previous *rsa.PrivateKey // Still validate tokens signed with this
}
func (ks *KeySet) ValidateToken(tokenString string) (*jwt.Token, error) {
keyFunc := func(key *rsa.PrivateKey) jwt.Keyfunc {
return func(t *jwt.Token) (interface{}, error) {
// Verify the signing algorithm to prevent key confusion attacks
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return &key.PublicKey, nil
}
}
// Try current key first
token, err := jwt.Parse(tokenString, keyFunc(ks.Current))
if err == nil {
return token, nil
}
// Fall back to previous key
token, err = jwt.Parse(tokenString, keyFunc(ks.Previous))
return token, err
}
The 12-Factor App: Configuration
The 12-Factor App methodology states: store configuration in the environment. Configuration that changes between environments (database URLs, API keys, feature flags) should be environment variables — not files in the repo.
This means:
- Development:
.envfile loaded by your app - Staging: environment variables set by CI/CD
- Production: environment variables from a secret manager or orchestrator (Kubernetes, Docker Compose)
Prevention Checklist
| Defense | Priority | Notes |
|---|---|---|
| Never hardcode secrets in code | High | Use environment variables at minimum |
Add .env to .gitignore | High | Before your first commit |
Commit .env.example with placeholders | Medium | Helps other developers |
| Use CI/CD secrets for pipelines | High | GitHub Actions secrets, GitLab variables |
| Use a secret manager in production | High | Vault, AWS Secrets Manager, GCP Secret Manager |
| Install pre-commit hooks (gitleaks) | High | Catches secrets before they are committed |
| Enable GitHub secret scanning | Medium | Free for public repos, paid for private |
| Rotate secrets on a schedule | Medium | 90 days for passwords and API keys |
| Use dynamic secrets when possible | Low | Vault dynamic database credentials |
| Never log secrets | High | Mask in logs and error messages |
What is Next?
In the next tutorial, you will learn about security headers — CSP, HSTS, X-Frame-Options, and other HTTP headers that protect your application from common attacks. These are simple to add and make a big difference.