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 .env files — add to .gitignore before your first commit
  • Create a .env.example — commit this with placeholder values so other developers know which variables are needed
  • Different .env per environment.env.development, .env.staging, .env.production
  • Do not use .env in 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:

ProviderServicePricing
AWSSecrets Manager$0.40/secret/month + $0.05 per 10K API calls
Google CloudSecret Manager$0.06/secret version/month + $0.03 per 10K access
AzureKey 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:

  1. Go to repository Settings > Code security and analysis
  2. Enable Secret scanning
  3. 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 TypeRotation FrequencyNotes
Database passwordsEvery 90 daysUse dynamic secrets if possible
API keysEvery 90 daysIssue new key before revoking old
JWT signing keysEvery 6 monthsSupport multiple active keys during rotation
Encryption keysAnnuallyRe-encrypt data with new key
SSH keysAnnuallyDeploy 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: .env file 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

DefensePriorityNotes
Never hardcode secrets in codeHighUse environment variables at minimum
Add .env to .gitignoreHighBefore your first commit
Commit .env.example with placeholdersMediumHelps other developers
Use CI/CD secrets for pipelinesHighGitHub Actions secrets, GitLab variables
Use a secret manager in productionHighVault, AWS Secrets Manager, GCP Secret Manager
Install pre-commit hooks (gitleaks)HighCatches secrets before they are committed
Enable GitHub secret scanningMediumFree for public repos, paid for private
Rotate secrets on a scheduleMedium90 days for passwords and API keys
Use dynamic secrets when possibleLowVault dynamic database credentials
Never log secretsHighMask 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.