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 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.yamland only addingconfig.yaml.examplealongside it, without actually addingconfig.yamlto.gitignorefirst - if the real file was already tracked before the.gitignoreentry was added, it stays in the repository and its history regardless of the ignore rule. - Reading credentials with
os.Getenvcorrectly, but setting the real production value directly in aDockerfileENVinstruction 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 localconfig.yamlwith 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
- AWS Secrets Manager
- Azure Key Vault
- CWE-798: Use of Hard-coded Credentials
- Git-secrets Tool (prevent committing secrets)
- Google Secret Manager
- HashiCorp Vault
- OWASP Secrets Management Cheat Sheet
- The 12-Factor App: Config
- TruffleHog (detect secrets in Git history)