Skip to content

CWE-798: Use of Hard-coded Credentials - Go

Overview

Hard-coded credentials in Go applications occur when passwords, API keys, cryptographic keys, database connection strings, or tokens are embedded directly in source code, configuration files committed to version control, or compiled binaries. These credentials become accessible to anyone with access to the codebase, version history, or binary - including attackers who obtain source code through repository breaches, insider threats, decompilation, or public code exposure.

What an attacker gets depends on what was embedded. Hard-coded database passwords grant unauthorized database access, allowing data theft or modification. Hard-coded API keys enable attackers to abuse external services at the victim's expense. Hard-coded encryption keys allow decryption of supposedly encrypted data. Hard-coded admin passwords provide full system access. Once credentials are committed to version control (Git), they persist in history indefinitely - even if later removed, they remain in old commits accessible to anyone who clones the repository.

Common patterns include credentials in connection strings, encryption keys as byte slices in code, API keys in constants, default passwords that aren't changed, and credentials in configuration files tracked by version control. Go applications often commit .env files, config.yaml files, or database initialization scripts containing credentials. Compiled binaries contain the string literals, extractable with strings.

Primary Defence: Use a secret management system (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault, Google Secret Manager) for production secrets. Environment variables or untracked files can be acceptable injection mechanisms for simpler deployments, but they still need access control, log redaction, dump protection, and rotation procedures. Use placeholder values in tracked config files, with real values injected at deployment. Rotate credentials regularly. Never commit credentials to version control.

Rotate first, then refactor. A credential that has already been committed is compromised regardless of whether the repository is public. It is in git log, in every clone, and - because Go links string literals into the binary - in every artefact built since, recoverable with strings ./binary. Deleting the literal from HEAD changes none of that. Revoke the value at the system that issued it before or alongside the code change.

Common Vulnerable Patterns

Hard-coded Database Password

// VULNERABLE - Database credentials in source code
package main

import (
    "database/sql"
    "fmt"

    _ "github.com/lib/pq"
)

func connectToDatabase() (*sql.DB, error) {
    // DANGEROUS: Hard-coded credentials
    connStr := "host=db.example.com port=5432 user=admin password=SuperSecret123! dbname=production sslmode=disable"

    db, err := sql.Open("postgres", connStr)
    if err != nil {
        return nil, err
    }

    return db, nil
}

// VULNERABILITY:
// 1. Password visible in source code
// 2. Exposed in version control history
// 3. Visible in compiled binary (strings command)
// 4. Shared with all developers who have repository access
// 5. Difficult to rotate without code changes

Why this is vulnerable: The password SuperSecret123! is embedded directly in the code. Anyone with repository access sees it. When committed to Git, the password lives forever in history - even if changed in later commits, git log and git blame reveal it. Compiled binaries contain the string literal, extractable with strings ./binary | grep password. Developers might share the code publicly, upload to GitHub by mistake, or be targeted by attackers seeking credentials. Rotating the password requires code changes, build, and deployment - a slow process during security incidents.

Hard-coded API Keys

// VULNERABLE - API keys as constants
package main

import (
    "fmt"
    "net/http"
)

const (
    // DANGEROUS: API keys in source code
    StripeAPIKey = "sk_live_51234567890abcdefghijklmnop"
    AWSAccessKey = "AKIAIOSFODNN7EXAMPLE"
    AWSSecretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
)

func chargeCustomer(amount int, token string) error {
    req, _ := http.NewRequest("POST", "https://api.stripe.com/v1/charges", nil)
    req.Header.Set("Authorization", "Bearer "+StripeAPIKey)

    // Make request...
    return nil
}

// RISKS:
// - Keys leaked to any repository viewer
// - Keys visible in compiled binaries
// - Cannot rotate without redeployment
// - Accidental public repository pushes expose keys
// - Stripe charges, AWS resources created by attackers

Why this is vulnerable: API keys grant access to paid services. Exposed Stripe keys allow attackers to create fraudulent charges or refunds. AWS keys provide full cloud access - attackers can spin up expensive resources, access data, or pivot to other systems. Declaring keys as constants makes them prominently visible in code and easy to grep for (grep -r "sk_live" .). Public repository scanners automatically detect and report API keys (GitHub secret scanning), often resulting in immediate key revocation, breaking production.

Encryption Keys in Code

// VULNERABLE - Hard-coded encryption key
import (
    "crypto/aes"
    "crypto/cipher"
)

var (
    // DANGEROUS: Encryption key in source code
    encryptionKey = []byte("32-byte-aes-key-for-encryption!!")
)

func encryptData(plaintext []byte) ([]byte, error) {
    block, err := aes.NewCipher(encryptionKey)
    if err != nil {
        return nil, err
    }

    gcm, err := cipher.NewGCM(block)
    if err != nil {
        return nil, err
    }

    // Encryption logic...
    return nil, nil
}

// VULNERABILITY:
// Anyone with code access can decrypt all encrypted data
// Key rotation requires code changes
// Historical commits reveal old keys that may still decrypt old data

Why this is vulnerable: Encryption provides no security if the key is public: database, file, or token encryption is worthless once the key sits in source code. Attackers who gain read-only repository access can decrypt all data. The key persists in version history forever. If different branches use different keys, managing which key decrypts which data becomes complex. Key rotation requires coordinated code deployment and data re-encryption, often prohibitively expensive. Encryption keys should be treated like passwords - stored in secure key management systems, never in code.

Configuration Files with Credentials in Git

# config.yaml - VULNERABLE (committed to Git)
database:
  host: localhost
  port: 5432
  username: admin
  password: ProductionPassword123  # DANGEROUS

aws:
  access_key: AKIAIOSFODNN7EXAMPLE  # DANGEROUS
  secret_key: wJalrXUtnFEMI/K7MDENG  # DANGEROUS
// VULNERABLE - Configuration Files with Credentials in Git
// Code that loads config
package main

import (
    "gopkg.in/yaml.v3"
    "os"
)

type Config struct {
    Database struct {
        Host     string `yaml:"host"`
        Port     int    `yaml:"port"`
        Username string `yaml:"username"`
        Password string `yaml:"password"`
    } `yaml:"database"`
}

func loadConfig() (*Config, error) {
    data, err := os.ReadFile("config.yaml")
    if err != nil {
        return nil, err
    }

    var config Config
    err = yaml.Unmarshal(data, &config)
    return &config, err
}

// VULNERABILITY:
// config.yaml tracked in Git
// Contains production credentials
// Visible to all developers and in version history

Why this is vulnerable: Configuration files tracked by version control inherit all the same risks as hard-coded credentials. Developers often commit config.yaml, .env, or database.yml files containing production credentials during initial development, forgetting to remove them before committing. Git history preserves these files even if later deleted. The .gitignore file must exclude credential-containing files from the start - adding files to .gitignore after they've been committed doesn't remove them from history.

Default Credentials Not Changed

// VULNERABLE - Default credentials
package main

import (
    "fmt"
    "net/http"
)

var (
    // DANGEROUS: Default admin credentials
    defaultAdminUser = "admin"
    defaultAdminPass = "admin123"
)

func loginHandler(w http.ResponseWriter, r *http.Request) {
    username := r.FormValue("username")
    password := r.FormValue("password")

    // Check against default credentials
    if username == defaultAdminUser && password == defaultAdminPass {
        // Grant admin access
        grantAdminSession(w)
        return
    }

    // Check database for other users
    checkDatabase(username, password)
}

// RISK:
// Default credentials are public knowledge
// Attackers try "admin/admin123" first on every deployment
// Often forgotten in production systems

Why this is vulnerable: Default credentials are well-known and widely shared. Attackers scan for services using default passwords (admin/admin, admin/password, root/toor). Many data breaches result from unchanged default credentials on databases, admin panels, or IoT devices. Even if intended only for initial setup, default credentials often remain active in production. Security scanners and penetration testers check for default credentials in the first minutes of assessment. Systems should force credential changes on first use and prevent setting credentials to known defaults.

Secure Patterns

Credentials the Product Accepts: Authenticate, Do Not Compare

Take this one first. It is the fix for Default Credentials Not Changed above, and none of the secret-management patterns below address it: the built-in administrator is not a secret the product needs to hold, it is an authenticator the product should never have accepted. Moving admin123 into Vault leaves the same credential working on every installation.

// SECURE - no credential is identical across installations
package main

import (
    "crypto/subtle"
    "database/sql"
    "net/http"

    "golang.org/x/crypto/bcrypt"
)

// A hash of a value no one knows, compared against when the user does not
// exist so that the failure path costs the same as a real verification.
var dummyHash = []byte("$2a$12$C6UzMDM.H6dfI/f/IKcEe.7jUQ3gQF0UqZ2k5k0m8kAqZ8Rq5wVLy")

func setupComplete(db *sql.DB) bool {
    var n int
    if err := db.QueryRow("SELECT count(*) FROM admin_users").Scan(&n); err != nil {
        return false
    }
    return n > 0
}

func lookupPasswordHash(db *sql.DB, username string) ([]byte, error) {
    var hash []byte
    err := db.QueryRow("SELECT password_hash FROM users WHERE username = $1", username).Scan(&hash)
    return hash, err
}

func loginHandler(db *sql.DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // Refuse traffic until an administrator has been enrolled. A fresh
        // install has no working credential rather than a well-known one.
        if !setupComplete(db) {
            http.Error(w, "setup incomplete", http.StatusServiceUnavailable)
            return
        }

        username := r.FormValue("username")
        password := r.FormValue("password")

        hash, err := lookupPasswordHash(db, username)
        if err != nil {
            // Same answer and comparable cost for "no such user".
            _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
            http.Error(w, "invalid credentials", http.StatusUnauthorized)
            return
        }

        if err := bcrypt.CompareHashAndPassword(hash, []byte(password)); err != nil {
            http.Error(w, "invalid credentials", http.StatusUnauthorized)
            return
        }

        grantSession(w, username)
    }
}

// Where the product really must accept a fixed token - a deployment-scoped key
// issued at install time, never one compiled in - compare it in constant time.
func validAPIKey(presented, expected []byte) bool {
    return subtle.ConstantTimeCompare(presented, expected) == 1
}

Why this works: there is no package-level credential left to compare against, so there is nothing an attacker can read out of the binary and use against every other deployment. bcrypt.CompareHashAndPassword verifies a per-user hash rather than testing equality against a literal, and it is already constant-time with respect to the hash; the dummy comparison on the unknown-user path keeps the two outcomes similar in cost, which is CWE-208 territory. setupComplete is what stops the default coming back: a build that refuses to serve until enrolment finishes cannot ship with a working admin/admin123, where a default that merely logs a warning survives. Use subtle.ConstantTimeCompare for any fixed token the product genuinely accepts - == on a secret leaks its length and its matching prefix through timing.

Environment Variables for Credentials

// SECURE - Loading credentials from environment variables
package main

import (
    "database/sql"
    "fmt"
    "os"

    _ "github.com/lib/pq"
)

func connectToDatabase() (*sql.DB, error) {
    // SECURE - Read credentials from environment
    host := os.Getenv("DB_HOST")
    port := os.Getenv("DB_PORT")
    user := os.Getenv("DB_USER")
    password := os.Getenv("DB_PASSWORD")
    dbname := os.Getenv("DB_NAME")

    // Validate required environment variables
    if host == "" || user == "" || password == "" {
        return nil, fmt.Errorf("missing required database environment variables")
    }

    // Set defaults for optional variables
    if port == "" {
        port = "5432"
    }
    if dbname == "" {
        dbname = "production"
    }

    // Build connection string
    connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=require",
        host, port, user, password, dbname)

    db, err := sql.Open("postgres", connStr)
    if err != nil {
        return nil, err
    }

    return db, nil
}

// Deploy with environment variables:
// export DB_HOST=db.example.com
// export DB_USER=app_user
// export DB_PASSWORD=<from your secret store, not typed here>
// export DB_NAME=production
// ./myapp

Why this works: Environment variables separate credentials from code and allow different environments to provide different values without source changes. They are not committed to version control by default, but they can still leak through process inspection, debug dumps, orchestration metadata, or logs - see CWE-526. Prefer injecting them from a managed secret store, and plan for restart, reload, or connection refresh during rotation.

Configuration Files Excluded from Version Control

# config.yaml.example - SECURE (tracked in Git)
database:
  host: localhost
  port: 5432
  username: YOUR_DB_USERNAME
  password: YOUR_DB_PASSWORD

aws:
  access_key: YOUR_AWS_ACCESS_KEY
  secret_key: YOUR_AWS_SECRET_KEY

# config.yaml - NOT tracked (in .gitignore)
database:
  host: db.prod.example.com
  port: 5432
  username: prod_user
  password: EXAMPLE_NOT_A_REAL_PASSWORD

aws:
  access_key: AKIAIOSFODNN7EXAMPLE
  secret_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
# .gitignore
config.yaml
.env
secrets/
*.key
*.pem

.gitignore keeps a secret out of the repository, and nothing more. The file still sits in plaintext on every machine that deploys, is readable by any process running as that user, and is copied into images, backups and support bundles without anyone deciding to. It also fails open: a rename, a new directory, or git add -f puts the file back in the history, and the pattern protects nothing retroactively if it was ever committed.

Treat this pattern as the floor rather than the goal. The secret-manager examples earlier on this page are what removes the file; use this one where the deployment genuinely cannot reach a manager, and keep the file permissions tight (0600, owned by the service account) rather than relying on the ignore rule alone.

// Code that loads the untracked config file
package main

import (
    "fmt"
    "gopkg.in/yaml.v3"
    "os"
)

type Config struct {
    Database struct {
        Host     string `yaml:"host"`
        Port     int    `yaml:"port"`
        Username string `yaml:"username"`
        Password string `yaml:"password"`
    } `yaml:"database"`
}

func loadConfig(path string) (*Config, error) {
    // SECURE - Load credentials from untracked config file
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("config file not found: %w\nCopy config.yaml.example to config.yaml and configure", err)
    }

    var config Config
    if err := yaml.Unmarshal(data, &config); err != nil {
        return nil, err
    }

    // Validate required fields
    if config.Database.Password == "YOUR_DB_PASSWORD" {
        return nil, fmt.Errorf("config.yaml contains placeholder values - please configure real credentials")
    }

    return &config, nil
}

func main() {
    config, err := loadConfig("config.yaml")
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }

    // Use config...
}

Why this works: config.yaml.example provides template documentation tracked in Git, showing developers what configuration is needed without exposing real credentials. .gitignore prevents config.yaml (containing real credentials) from being committed. Developers copy the example and fill in their credentials locally. Each environment (dev, staging, prod) has its own config.yaml with appropriate credentials. The validation check prevents deploying with placeholder values. Deployment documentation instructs operators to create config.yaml with real credentials during deployment.

Secret Management Systems

// SECURE - Using AWS Secrets Manager
package main

import (
    "context"
    "encoding/json"
    "fmt"
    "strings"

    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
)

// quoteDSN wraps a keyword/value element in single quotes and escapes the two
// characters libpq treats specially inside them.
func quoteDSN(value string) string {
    escaped := strings.ReplaceAll(value, `\`, `\\`)
    escaped = strings.ReplaceAll(escaped, `'`, `\'`)
    return "'" + escaped + "'"
}

type DatabaseCredentials struct {
    Host     string `json:"host"`
    Port     int    `json:"port"`
    Username string `json:"username"`
    Password string `json:"password"`
    DBName   string `json:"dbname"`
}

func getDBCredentialsFromSecretsManager(ctx context.Context, secretName string) (*DatabaseCredentials, error) {
    // SECURE - Load from AWS Secrets Manager
    cfg, err := config.LoadDefaultConfig(ctx)
    if err != nil {
        return nil, err
    }

    client := secretsmanager.NewFromConfig(cfg)

    result, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
        SecretId: &secretName,
    })
    if err != nil {
        return nil, fmt.Errorf("failed to retrieve secret: %w", err)
    }

    var creds DatabaseCredentials
    if err := json.Unmarshal([]byte(*result.SecretString), &creds); err != nil {
        return nil, err
    }

    return &creds, nil
}

func connectWithSecretsManager(ctx context.Context) error {
    // Retrieve credentials from Secrets Manager
    creds, err := getDBCredentialsFromSecretsManager(ctx, "prod/database/credentials")
    if err != nil {
        return err
    }

    // Quote the values that came from the secret store. A generated password
    // containing a space breaks the keyword/value parse - see the note below.
    connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s",
        quoteDSN(creds.Host), creds.Port, quoteDSN(creds.Username),
        quoteDSN(creds.Password), quoteDSN(creds.DBName))

    // Connect to database...
    fmt.Println("Connected with credentials from Secrets Manager")
    return nil
}

Why this works: AWS Secrets Manager and similar services centralize secret storage with access controls, audit logging, rotation support, and encryption at rest. Credentials are retrieved at application startup or periodically refreshed. IAM roles and policies control which applications can access which secrets. Credential rotation does not require code changes, but the application still needs restart, reload, or cache refresh behavior to pick up new values. Audit logs track secret access, and secrets stay out of code and version control.

One thing changes once the password stops being a literal you chose: it is now whatever the generator produced. A libpq keyword/value string splits on whitespace, so password=p ss w0rd fails with missing "=" after "ss" - a confusing error at the first connection, not at the read. Wrap any value that comes from a secret store in single quotes and escape ' and \ inside it (password='p ss w0rd'), which is what quoteDSN above does. The alternative is to assemble a postgres:// URL with net/url and hand that string to pq.ParseURL, which emits an already-quoted keyword/value string.

HashiCorp Vault Integration

// SECURE - Using HashiCorp Vault
import (
    "fmt"
    "os"

    vault "github.com/hashicorp/vault/api"
)

func getSecretFromVault(secretPath string) (map[string]interface{}, error) {
    // SECURE - Vault configuration from environment
    config := vault.DefaultConfig()
    config.Address = os.Getenv("VAULT_ADDR") // e.g., https://vault.example.com

    client, err := vault.NewClient(config)
    if err != nil {
        return nil, err
    }

    // Authenticate with token (in production, use AppRole or cloud auth)
    client.SetToken(os.Getenv("VAULT_TOKEN"))

    // Read secret
    secret, err := client.Logical().Read(secretPath)
    if err != nil {
        return nil, err
    }

    if secret == nil || secret.Data == nil {
        return nil, fmt.Errorf("secret not found")
    }

    return secret.Data, nil
}

func getAPIKey(keyName string) (string, error) {
    secretPath := fmt.Sprintf("secret/data/api-keys/%s", keyName)

    data, err := getSecretFromVault(secretPath)
    if err != nil {
        return "", err
    }

    // Vault v2 secrets have nested data structure
    dataField, ok := data["data"].(map[string]interface{})
    if !ok {
        return "", fmt.Errorf("invalid secret format")
    }

    apiKey, ok := dataField["api_key"].(string)
    if !ok {
        return "", fmt.Errorf("api_key not found in secret")
    }

    return apiKey, nil
}

Why this works: Vault provides dynamic secrets, lease management, fine-grained access control, and audit logging. Applications should authenticate using a workload-appropriate method such as AppRole, Kubernetes auth, or cloud provider IAM; avoid long-lived static tokens where possible. Secrets are fetched at runtime and kept out of source code. Vault encrypts secrets at rest and in transit. Access policies control which applications can read which secrets, and audit logs record secret access for compliance and monitoring.

Encrypted Configuration with KMS

// SECURE - Encrypt sensitive config values with AWS KMS
import (
    "context"
    "encoding/base64"

    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/kms"
)

func encryptConfigValue(ctx context.Context, plaintext, keyID string) (string, error) {
    cfg, err := config.LoadDefaultConfig(ctx)
    if err != nil {
        return "", err
    }

    client := kms.NewFromConfig(cfg)

    result, err := client.Encrypt(ctx, &kms.EncryptInput{
        KeyId:     &keyID,
        Plaintext: []byte(plaintext),
    })
    if err != nil {
        return "", err
    }

    // Return base64-encoded ciphertext
    return base64.StdEncoding.EncodeToString(result.CiphertextBlob), nil
}

func decryptConfigValue(ctx context.Context, ciphertext string) (string, error) {
    ciphertextBlob, err := base64.StdEncoding.DecodeString(ciphertext)
    if err != nil {
        return "", err
    }

    cfg, err := config.LoadDefaultConfig(ctx)
    if err != nil {
        return "", err
    }

    client := kms.NewFromConfig(cfg)

    result, err := client.Decrypt(ctx, &kms.DecryptInput{
        CiphertextBlob: ciphertextBlob,
    })
    if err != nil {
        return "", err
    }

    return string(result.Plaintext), nil
}

// config.yaml can now contain encrypted values:
// database:
//   password: "AQICAHi...encrypted-base64-string..."
//
// Application decrypts at startup using KMS

Why this works: KMS encrypts configuration values, allowing config files to be committed to version control (encrypted ciphertext visible, but plaintext protected). Only applications/users with KMS decrypt permission can read values. KMS handles key management, rotation, and audit logging. The encryption key never leaves KMS - it's not exposed to applications. Access control is managed via IAM policies. This works well for compliance requirements mandating encryption of credentials at rest.

Common Pitfalls

  • Replacing a hard-coded password in a Go source file with the same value in a committed config.yaml and only adding config.yaml.example alongside it, without actually adding config.yaml to .gitignore first - if the real file was already tracked before the .gitignore entry was added, it stays in the repository and its history regardless of the ignore rule.
  • Reading credentials with os.Getenv correctly, but setting the real production value directly in a Dockerfile ENV instruction or a Kubernetes manifest that is version-controlled - the app no longer has a string literal in Go source, but the secret is still committed in plaintext, just one file removed from where it used to be.
  • Loading secrets from AWS Secrets Manager or Vault only in the main() startup path while a separate CLI tool, migration script, or test helper in the same repository still reads a local config.yaml with real credentials for convenience - the secrets-manager migration is real but incomplete, and the weaker path is still exploitable.
  • Logging the full config struct (log.Printf("%+v", config)) for debugging after loading credentials from a secrets manager - centralizing storage in Secrets Manager doesn't help if the retrieved values are then written to application logs, which are often less access-controlled than the secrets manager itself.

Additional Resources