Hardcoded credentials are one of the most common and most preventable vulnerability classes out there. API keys, database passwords, encryption keys, and service tokens embedded directly in source code end up in version control, build artifacts, container images, and log files. Once a secret reaches a Git repository, it persists in the history even after the offending line is deleted. When I started researching how often this happens in practice, the numbers were staggering, public reports of leaked credentials on GitHub alone run into the millions per year. In this post I’ll cover the patterns that lead to hardcoded secrets, the tools that detect them, and the architecture changes that eliminate them for good.

Why Secrets End Up in Code

The reasons are predictable, and understanding them matters more than blaming developers:

  1. Quick prototyping: “I’ll move it to an environment variable later”, and then the prototype becomes the production code. This is probably the most common path, and it’s a process failure, not a character flaw.
  2. Test fixtures: Test suites need credentials for integration tests, and developers embed them directly. I’ve run into production AWS keys in test files during code reviews, the developer had copy-pasted from their local config.
  3. Configuration defaults: A default password in a config file that was never changed in production. This one is especially common in Docker images and Helm charts.
  4. Copy-paste from documentation: Cloud provider quickstart guides often include example keys that developers replace with real ones in the same file. It’s worth grepping for EXAMPLE and CHANGEME, you’d be surprised how often the real key is right next to the placeholder.

The problem isn’t developer negligence, it’s that the path of least resistance leads directly to hardcoded secrets. The only way to fix that systemically is to make the secure path easier than the insecure one.

The Patterns

Python, Direct Assignment

import boto3

AWS_ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE"
AWS_SECRET_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"

s3 = boto3.client(
    "s3",
    aws_access_key_id=AWS_ACCESS_KEY,
    aws_secret_access_key=AWS_SECRET_KEY,
)

DATABASE_URL = "postgresql://admin:SuperSecret123@db.example.com:5432/production"

JWT_SECRET = "my-super-secret-jwt-key-that-should-not-be-here"

These are the obvious cases. The variable names and string patterns make them easy to detect. But secrets also hide in less obvious places:

# Secret in a default parameter
def connect_to_cache(host="redis.internal", password="cache_p@ss_2024"):
    return redis.Redis(host=host, password=password)

# Secret in a class attribute
class PaymentGateway:
    API_KEY = "sk_live_4eC39HqLyjWDarjtT1zdp7dc"

    def charge(self, amount, token):
        return requests.post(
            "https://api.stripe.com/v1/charges",
            auth=(self.API_KEY, ""),
            data={"amount": amount, "source": token},
        )

The default parameter pattern is one worth always checking for. Bandit catches it (B105), but it can slip through reviews because the function signature looks innocuous. The class attribute pattern is even sneakier, it looks like a constant, and developers treat it like configuration rather than a secret.

Java, Constants and Configuration

public class DatabaseConfig {
    private static final String DB_PASSWORD = "pr0duction_p@ssw0rd!";
    private static final String DB_URL =
        "jdbc:mysql://db.example.com:3306/app?user=root&password=" + DB_PASSWORD;

    public static Connection getConnection() throws SQLException {
        return DriverManager.getConnection(DB_URL);
    }
}

public class AppConfig {
    private String apiKey = "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";

    public String getApiKey() {
        return apiKey;
    }
}

Java applications frequently embed secrets in application.properties, application.yml, or Spring Boot configuration files that get committed alongside the code. The .properties file format makes secrets look like configuration rather than code, which reduces the perceived risk. I’ve seen Spring Boot projects where the application.properties file contained database passwords, API keys, and JWT secrets, all committed to the main branch. The developers genuinely didn’t think of it as “hardcoding” because it was in a config file, not in Java code. That mental model is the root cause.

Go, Const and Init Blocks

package main

import (
    "database/sql"
    "net/http"
    _ "github.com/lib/pq"
)

const (
    dbHost     = "db.example.com"
    dbUser     = "admin"
    dbPassword = "g0_pr0duction_s3cret"
    dbName     = "appdb"
    apiToken   = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef"
)

func initDB() (*sql.DB, error) {
    connStr := fmt.Sprintf(
        "host=%s user=%s password=%s dbname=%s sslmode=disable",
        dbHost, dbUser, dbPassword, dbName,
    )
    return sql.Open("postgres", connStr)
}

func callExternalAPI(endpoint string) (*http.Response, error) {
    req, _ := http.NewRequest("GET", endpoint, nil)
    req.Header.Set("Authorization", "Bearer "+apiToken)
    return http.DefaultClient.Do(req)
}

Go’s const blocks make secrets look like intentional, permanent values. The compiler inlines constants, so the secret appears in the binary even if the source is not distributed. This pattern is especially common in Go microservices where developers set up the project quickly and never circle back to externalize the configuration. The const keyword gives it an air of legitimacy that var doesn’t, something about declaring a value as constant makes it feel like it belongs there.

JavaScript, Environment Fallbacks

const express = require('express');
const jwt = require('jsonwebtoken');

const JWT_SECRET = process.env.JWT_SECRET || "default-secret-change-me";
const DB_PASSWORD = process.env.DB_PASS || "localdev123";
const API_KEY = process.env.API_KEY || "sk_test_4eC39HqLyjWDarjtT1zdp7dc";

const app = express();

app.post('/login', (req, res) => {
    const { username, password } = req.body;
    // ... authenticate ...
    const token = jwt.sign({ sub: username }, JWT_SECRET, { expiresIn: '1h' });
    res.json({ token });
});

The process.env.X || "fallback" pattern is the one that worries me most. It looks like the developer intended to use environment variables, and they probably did. But if the environment variable is not set in production (a common deployment oversight), the hardcoded fallback becomes the production secret. This happens in real deployments where the .env file was in .gitignore but nobody set up the environment variables in the container orchestrator. The app starts fine, signs JWTs with "default-secret-change-me", and nobody notices until someone investigates an unrelated issue weeks later.

Detection Strategies

Automated Secret Scanning

Several tools exist for detecting secrets in source code and Git history, and they each have different strengths:

  • git-secrets (AWS): Pre-commit hook that prevents committing AWS credentials. Pattern-based, focused on AWS key formats. Good baseline for any project that touches AWS.
  • truffleHog: Scans Git history for high-entropy strings and known secret patterns. This is the go-to for auditing existing repos, it catches secrets that were committed and then deleted.
  • detect-secrets (Yelp): Baseline-aware scanner that tracks known secrets and alerts on new ones. The baseline approach is particularly useful for reducing noise on legacy codebases.
  • gitleaks: Fast scanner with regex-based rules for 100+ secret types. Integrates well into CI pipelines.
  • GitHub Secret Scanning: Automatically scans public repositories for known secret formats from partner providers. Free on GitHub and catches real secrets regularly.

SAST Tool Coverage

General-purpose SAST tools also catch some hardcoded secrets, though they’re generally less thorough than dedicated secret scanners:

  • Bandit (Python): B105 (hardcoded password in function default), B106 (hardcoded password in function argument), B107 (hardcoded password in string).
  • SpotBugs (Java): DMI_CONSTANT_DB_PASSWORD, HARD_CODE_PASSWORD.
  • gosec (Go): G101 (hardcoded credentials pattern matching).
  • eslint-plugin-security (JavaScript): detect-non-literal-require (indirect), but limited secret detection.

Manual Review Indicators

When reviewing code for hardcoded secrets, here’s what’s worth searching for:

  1. Variable names containing: password, secret, key, token, credential, auth, api_key, apikey, access_key. A case-insensitive search across the entire repo is a good starting point.
  2. String patterns: Base64-encoded strings longer than 20 characters, strings matching known key formats (AKIA..., ghp_..., sk_live_..., -----BEGIN RSA PRIVATE KEY-----). These patterns are surprisingly reliable.
  3. Connection strings with embedded credentials: ://user:pass@host. Production database passwords show up in connection strings that were “only used for local development” more often than you’d expect.
  4. Default parameter values in authentication functions. This is the one most reviewers miss.
  5. Configuration files in version control: .env, application.properties, config.yml. Always check whether these are in .gitignore, and whether they were added before the .gitignore rule was created.

Remediation

Environment Variables

The simplest migration path, move secrets to environment variables and fail loudly when they’re missing:

import os

AWS_ACCESS_KEY = os.environ["AWS_ACCESS_KEY_ID"]
AWS_SECRET_KEY = os.environ["AWS_SECRET_ACCESS_KEY"]
dbPassword := os.Getenv("DB_PASSWORD")
if dbPassword == "" {
    log.Fatal("DB_PASSWORD environment variable is required")
}
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
    console.error("JWT_SECRET environment variable is required");
    process.exit(1);
}
String dbPassword = System.getenv("DB_PASSWORD");
if (dbPassword == null || dbPassword.isEmpty()) {
    throw new RuntimeException("DB_PASSWORD environment variable is required");
}

The key principle: fail loudly when a required secret is missing. Never provide a fallback value. Too many production incidents trace back to fallback secrets that were supposed to be temporary.

Secrets Managers

For production systems, a dedicated secrets manager is worth the extra complexity:

# AWS Secrets Manager
import boto3
import json

def get_secret(secret_name):
    client = boto3.client("secretsmanager")
    response = client.get_secret_value(SecretId=secret_name)
    return json.loads(response["SecretString"])

db_creds = get_secret("production/database")
// HashiCorp Vault
VaultTemplate vault = new VaultTemplate(vaultEndpoint, tokenAuth);
VaultResponseSupport<Map> response = vault.read("secret/data/database");
String password = (String) response.getData().get("password");

Git History Cleanup

If a secret was committed, changing it is not enough, the old value persists in Git history:

  1. Rotate the secret immediately. Assume it is compromised. Don’t debate whether anyone saw it, just rotate.
  2. Remove from history using git filter-branch or BFG Repo-Cleaner. BFG is faster and simpler for this specific use case.
  3. Force-push and notify all contributors to re-clone.
  4. Add pre-commit hooks to prevent recurrence.

Prevention Architecture

The architecture that eliminates hardcoded secrets:

  • Pre-commit hooks: Run detect-secrets or gitleaks before every commit. This is the single most effective prevention measure based on everything I’ve read and tried.
  • CI scanning: Run secret scanners on every pull request. Catch anything the pre-commit hook missed.
  • .gitignore patterns: Exclude .env, *.pem, *.key, credentials.json. Set this up on day one of the project.
  • Template files: Provide .env.example with placeholder values instead of real secrets. Always check that the example file exists and that the real file is gitignored.
  • Fail-closed defaults: Applications should refuse to start if required secrets are missing, not fall back to hardcoded values. This is the principle that prevents the process.env.X || "fallback" pattern from ever becoming a production issue.

The combination of pre-commit hooks and fail-closed defaults eliminates the vast majority of hardcoded secret incidents. The hooks catch the mistake before it enters version control, and the fail-closed defaults catch any that slip through by making the application crash loudly instead of running with a weak secret.