CWE-798: Use of Hard-coded Credentials - Java
Overview
Credentials should never be embedded in code, in configuration files committed to version control, or compiled into binaries: anyone who can read the repository or a build artifact can read them. Use environment variables, secrets managers, or configuration services instead.
Primary Defence: Use cloud or enterprise secrets managers (AWS Secrets Manager, Azure Key Vault, HashiCorp Vault) for production. Environment variables and externalized configuration are deployment-time injection mechanisms, not storage locations - see CWE-526.
Rotate first, then refactor. A credential that has been committed is
compromised regardless of whether the repository is public: it is in git log,
in every clone, and in the constant pool of every JAR built since, readable with
javap -c or any decompiler. 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 Credentials
// VULNERABLE - Credentials in source code
public class DatabaseConnection {
private static final String DB_URL = "jdbc:mysql://localhost:3306/mydb";
private static final String DB_USER = "admin";
private static final String DB_PASSWORD = "P@ssw0rd123"; // DANGEROUS!
public Connection getConnection() throws SQLException {
return DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
}
}
Why this is vulnerable: Hard-coded database credentials are visible to anyone with repository access, remain in git history permanently, are compiled into JAR files where they can be decompiled, and cannot be rotated without recompiling and redeploying the application.
Hard-coded API Keys
// VULNERABLE - API key in code
public class ApiClient {
private static final String API_KEY = "sk_live_51H7x8y9z10a11b12c"; // DANGEROUS!
private static final String API_SECRET = "whsec_abcdef123456"; // DANGEROUS!
public void makeRequest() {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + API_KEY);
// Make API call
}
}
Why this is vulnerable: API keys in source code are exposed to all developers with repository access, persist in version control history, are visible in decompiled bytecode, and enable unauthorized API usage or billing charges if the code is leaked or the repository is compromised.
Hard-coded Encryption Keys
// VULNERABLE - Encryption key in code
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
public class Encryptor {
private static final String SECRET_KEY = "MySecretKey12345"; // DANGEROUS!
public byte[] encrypt(String data) throws Exception {
SecretKeySpec keySpec = new SecretKeySpec(SECRET_KEY.getBytes(), "AES");
Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
return cipher.doFinal(data.getBytes());
}
}
Why this is vulnerable: Hard-coded encryption keys defeat the purpose of encryption since anyone with code access can decrypt the data, keys cannot be rotated without recompiling, and stolen keys compromise all historical encrypted data permanently.
Credentials in Properties Files (Committed to Git)
# VULNERABLE - application.properties committed to version control
database.url=jdbc:mysql://localhost:3306/mydb
database.username=admin
database.password=P@ssw0rd123
aws.access.key=AKIAIOSFODNN7EXAMPLE
aws.secret.key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
Why this is vulnerable: Properties files committed to git leave the credentials in version control history, where they stay readable after the file is deleted, are cloned by every developer and CI/CD system, and are often deployed to production where they can be read from the filesystem or extracted from deployment artifacts.
Credentials the Product Itself Accepts
// VULNERABLE - the credential is compiled into the product, not held by it
public class AdminLogin {
private static final String ADMIN_USER = "admin";
private static final String ADMIN_PASSWORD = "admin123"; // DANGEROUS!
// A fixed digest is a fixed credential. Hashing it changes nothing.
private static final String SUPPORT_TOKEN_SHA256 =
"240be518fabd2724ddb6f04eeb1da5967448d7e831c08c8fa822809f74c720a9"; // DANGEROUS!
public boolean login(String username, String password) {
// Built-in account: the same username and password on every install
if (ADMIN_USER.equals(username) && ADMIN_PASSWORD.equals(password)) {
return true;
}
return verifyAgainstUserTable(username, password);
}
public boolean supportBackdoor(String token) {
// Maintenance path, no per-technician identity and no audit trail
return sha256Hex(token).equals(SUPPORT_TOKEN_SHA256);
}
}
Why this is vulnerable: this is the inbound half of CWE-798 and a secrets manager does not fix it. The values are not secrets the product needs to hold, they are authenticators it should never have accepted, and moving admin123 into Vault or an environment variable leaves the same credential working on every installation - the attacker's own copy of the product is as good as the customer's. The comparisons are also invisible to the grep that finds the outbound half: the pattern here is equals against a literal in an authentication path, not password = "...". The hashed support token is no better than a plaintext one: a digest that is identical in every build is a credential that is identical in every build, and String.equals on it leaks the matching prefix through timing (CWE-208). SUPPORT_TOKEN_SHA256 above is simply the SHA-256 of admin123, which any rainbow table returns instantly.
Secure Patterns
Credentials the Product Accepts: Authenticate, Do Not Compare
Take this one first. It is the fix for Credentials the Product Itself Accepts
above, and none of the secret-management patterns that follow address it. There
is no correct place to keep a password that is identical on every installation,
so the credential has to be deleted rather than relocated, and the account it
guarded replaced with one enrolled per installation.
// SECURE - no credential the product accepts is the same on every installation
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Arrays;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.sql.DataSource;
public final class AdminAuthenticator {
public enum Outcome { SETUP_REQUIRED, DENIED, GRANTED }
// PBKDF2-HMAC-SHA256 at the iteration count CWE-916 lists for this PRF.
private static final String KDF = "PBKDF2WithHmacSHA256";
private static final int ITERATIONS = 600_000;
private static final int KEY_BITS = 256;
private static final int SALT_BYTES = 16;
private final DataSource dataSource;
private final byte[] dummySalt = new byte[SALT_BYTES];
public AdminAuthenticator(DataSource dataSource) {
this.dataSource = dataSource;
new SecureRandom().nextBytes(dummySalt);
}
public Outcome login(String username, char[] password) throws SQLException {
try (Connection conn = dataSource.getConnection()) {
// First-run enrolment gate. Until an administrator exists the
// product refuses to authenticate anyone, so there is no default
// account for a deployment to forget to change.
if (!administratorEnrolled(conn)) {
return Outcome.SETUP_REQUIRED;
}
byte[] salt = null;
byte[] storedHash = null;
try (PreparedStatement ps = conn.prepareStatement(
"SELECT salt, password_hash FROM users WHERE username = ?")) {
ps.setString(1, username);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
salt = rs.getBytes(1);
storedHash = rs.getBytes(2);
}
}
}
if (storedHash == null) {
// No such user: derive against a per-process random salt so the
// unknown-user path costs what a real verification costs.
Arrays.fill(derive(password, dummySalt), (byte) 0);
return Outcome.DENIED;
}
byte[] derived = derive(password, salt);
try {
return MessageDigest.isEqual(derived, storedHash)
? Outcome.GRANTED
: Outcome.DENIED;
} finally {
Arrays.fill(derived, (byte) 0);
}
} finally {
Arrays.fill(password, '\0');
}
}
// Called by the installer or the setup page, with a password the operator
// chooses. It is not a default: it exists only once someone has typed it.
public void enrolFirstAdministrator(String username, char[] password) throws SQLException {
try (Connection conn = dataSource.getConnection()) {
if (administratorEnrolled(conn)) {
throw new IllegalStateException("an administrator is already enrolled");
}
byte[] salt = new byte[SALT_BYTES];
new SecureRandom().nextBytes(salt);
byte[] hash = derive(password, salt);
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO users (username, salt, password_hash, is_administrator) "
+ "VALUES (?, ?, ?, TRUE)")) {
ps.setString(1, username);
ps.setBytes(2, salt);
ps.setBytes(3, hash);
ps.executeUpdate();
}
} finally {
Arrays.fill(password, '\0');
}
}
private static boolean administratorEnrolled(Connection conn) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement(
"SELECT count(*) FROM users WHERE is_administrator = TRUE");
ResultSet rs = ps.executeQuery()) {
return rs.next() && rs.getInt(1) > 0;
}
}
private static byte[] derive(char[] password, byte[] salt) {
PBEKeySpec spec = new PBEKeySpec(password, salt, ITERATIONS, KEY_BITS);
try {
return SecretKeyFactory.getInstance(KDF).generateSecret(spec).getEncoded();
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new IllegalStateException("PBKDF2 is unavailable", e);
} finally {
spec.clearPassword();
}
}
// For a fixed token the product genuinely must accept - one issued per
// installation at setup time, never one compiled in. Hashing both operands
// first keeps the comparison at a fixed 32 bytes, so neither the token's
// length nor its matching prefix is visible in the timing.
public static boolean tokenMatches(byte[] presented, byte[] expected) {
return MessageDigest.isEqual(sha256(presented), sha256(expected));
}
private static byte[] sha256(byte[] input) {
try {
return MessageDigest.getInstance("SHA-256").digest(input);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is unavailable", e);
}
}
}
Why this works: there is no credential literal left in the class, so there is
nothing to read out of the JAR with javap -c and replay against every other
deployment. Verification runs against a per-user salt and hash held in the
installation's own database, through SecretKeyFactory with
PBKDF2WithHmacSHA256 rather than a raw digest - see
CWE-916 for why a plain SHA-256 of a password is not a
password hash. administratorEnrolled is what stops the default coming back: a
build that returns SETUP_REQUIRED until enrolment finishes cannot ship with a
working admin/admin123, where a default that merely logs a warning survives
into production. MessageDigest.isEqual compares the derived key against the
stored one without the early exit that Arrays.equals and String.equals take
at the first differing byte, and tokenMatches uses it for the fixed-token case
after hashing both sides to a constant 32 bytes.
Two details that are easy to drop. The password travels as char[] rather than
String so it can be zeroed once it has been used; a String stays on the heap
until the collector happens to reach it, and a heap dump from a running process
is a realistic way for it to escape (CWE-316). And the
unknown-user path still runs a derivation, because returning immediately makes
"no such user" measurably faster than "wrong password" and hands an attacker a
username oracle - the same CWE-208 problem as the
comparison itself.
Environment Variables
// SECURE - Read from environment variables
public class DatabaseConnection {
private final String dbUrl;
private final String dbUser;
private final String dbPassword;
public DatabaseConnection() {
this.dbUrl = System.getenv("DB_URL");
this.dbUser = System.getenv("DB_USER");
this.dbPassword = System.getenv("DB_PASSWORD");
if (dbUrl == null || dbUser == null || dbPassword == null) {
throw new IllegalStateException("Database credentials not configured");
}
}
public Connection getConnection() throws SQLException {
return DriverManager.getConnection(dbUrl, dbUser, dbPassword);
}
}
// Set environment variables:
// export DB_URL=jdbc:mysql://localhost:3306/mydb
// export DB_USER=admin
// export DB_PASSWORD=SecurePassword123
Why this works: Environment variables decouple credentials from source code and let each environment provide its own values. The validation (throw new IllegalStateException) ensures fail-fast behavior if credentials are missing. In production, inject these variables from a secret store or platform secret mechanism and avoid logging or exposing the process environment - see CWE-526. Rotation requires an application reload, restart, or connection-pool refresh unless the application explicitly supports dynamic secret refresh.
Spring @Value with External Properties
// SECURE - Spring configuration from external properties
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class ApiClient {
@Value("${api.key}")
private String apiKey;
@Value("${api.secret}")
private String apiSecret;
public void makeRequest() {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + apiKey);
// Make API call
}
}
// application.properties - safe to commit: it holds only indirection
// api.key=${API_KEY}
// api.secret=${API_SECRET}
// application.yml - equivalent
/*
api:
key: ${API_KEY}
secret: ${API_SECRET}
*/
Why this works: Spring injects values from external sources (environment variables, config server, secret stores) through placeholders, so credentials never live in the compiled application or source control. The ${API_KEY} syntax defers resolution until runtime, letting you supply different secrets per environment without code changes. A placeholder with no value behind it fails to resolve, so the application refuses to start rather than running on a silent default. This approach pairs naturally with Spring Cloud Config, Vault, or container env vars, enabling central rotation and least-privilege access.
The file shown above should be committed, and it is worth being clear about
why, because "keep application.properties out of version control" is the wrong
lesson to take from this. A properties file containing ${API_KEY} holds no
secret - it is a declaration that a secret named API_KEY must be supplied,
which is exactly the record a new environment needs. What must not be committed
is the same file with a value substituted in. Reviewing this file is how a
reviewer notices when one has been.
AWS Secrets Manager
// SECURE - AWS Secrets Manager
import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient;
import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest;
import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
public class SecretsManager {
private final SecretsManagerClient client;
private final ObjectMapper objectMapper;
public SecretsManager() {
this.client = SecretsManagerClient.create();
// An RDS-managed secret also carries engine, dbname and
// dbInstanceIdentifier. Jackson rejects unknown fields by default.
this.objectMapper = new ObjectMapper()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
}
public DatabaseCredentials getDatabaseCredentials() {
GetSecretValueRequest request = GetSecretValueRequest.builder()
.secretId("prod/database/credentials")
.build();
GetSecretValueResponse response = client.getSecretValue(request);
String secretString = response.secretString();
try {
return objectMapper.readValue(secretString, DatabaseCredentials.class);
} catch (Exception e) {
throw new RuntimeException("Failed to parse credentials", e);
}
}
}
// Jackson binds through accessors or visible fields. A class with only
// private fields and no getters/setters has *zero* known properties, and
// readValue fails on the first key with UnrecognizedPropertyException.
public class DatabaseCredentials {
private String username;
private String password;
private String host;
private int port;
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getHost() { return host; }
public void setHost(String host) { this.host = host; }
public int getPort() { return port; }
public void setPort(int port) { this.port = port; }
}
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>secretsmanager</artifactId>
<version>2.54.4</version>
</dependency>
Why this works: AWS Secrets Manager provides centralized secret storage with encryption, automatic rotation, and fine-grained access control via IAM. Secrets are retrieved at runtime, not embedded in code. The SDK automatically uses IAM roles (EC2 instance profiles, ECS task roles) for authentication - no AWS credentials in code. Supports versioning, enabling gradual rollout of rotated secrets. CloudTrail logs all secret access for audit compliance.
secretString() returns a JSON document, not the secret, and the two things
that break the parse are both invisible until the first call. Jackson needs
accessors or a @JsonProperty on each field - a credentials class stubbed with
a // getters and setters comment binds nothing at all - and it rejects keys it
does not know, which an RDS-managed secret always has (engine, dbname,
dbInstanceIdentifier). Both fail after the secret has been fetched
successfully, so the finding looks fixed right up to the point the application
tries to connect. Prefer the version from the
AWS SDK BOM
rather than pinning each artifact, so the SDK modules stay aligned.
HashiCorp Vault
// SECURE - HashiCorp Vault integration
import java.util.Map;
import org.springframework.stereotype.Component;
import org.springframework.vault.core.VaultTemplate;
import org.springframework.vault.support.VaultResponse;
@Component
public class VaultService {
private final VaultTemplate vaultTemplate;
public VaultService(VaultTemplate vaultTemplate) {
this.vaultTemplate = vaultTemplate;
}
public String getDatabasePassword() {
// KV v2 puts the secret under data.data. VaultTemplate.read() hands back
// the API response's "data" object, so the payload is one level further
// in - indexing the response directly returns null for every key.
VaultResponse response = vaultTemplate.read("secret/data/database");
if (response == null || response.getData() == null) {
throw new RuntimeException("Failed to read secret from Vault");
}
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) response.getData().get("data");
if (data == null || data.get("password") == null) {
throw new RuntimeException(
"No password at secret/data/database - check the KV version and mount path");
}
return (String) data.get("password");
}
}
spring:
cloud:
vault:
host: vault.example.com
port: 8200
scheme: https
authentication: APPROLE # Prefer workload identity, Kubernetes auth, or AppRole in production
<dependency>
<groupId>org.springframework.vault</groupId>
<artifactId>spring-vault-core</artifactId>
<version>3.2.1</version>
</dependency>
The line to pin follows the Spring generation, not the highest number on Maven
Central. Spring Vault 2.x targets Spring Framework 5 and the javax.* namespace;
3.x is built against Spring Framework 6, which is what Spring Boot 3 runs; 4.x is
built against Spring Framework 7 and belongs with Spring Boot 4. Getting that
wrong does not fail the build, which is what makes it worth stating. Measured on
spring-boot-starter-parent 3.5.6 with spring-vault-core 4.1.0: the Boot BOM
holds spring-core and spring-web at 6.2.11, so only one Spring Framework is
ever on the classpath and the code above compiles - then the first
new VaultTemplate(...) throws NoSuchMethodError: 'void
org.springframework.web.client.RestTemplate.<init>(java.lang.Iterable)' from
VaultClients.createRestTemplate, because 4.x calls a constructor that only
Spring Framework 7 has. On 3.2.1 the same call constructs and reads normally.
Note also that spring-boot-dependencies does not manage spring-vault-core at
all: the version comes from Spring Cloud's BOM when Vault is reached through
spring-cloud-starter-vault-config - which is what the spring.cloud.vault
properties above are - and from this explicit <version> when it is not. 3.2.1
is the current 3.x release.
Why this works: HashiCorp Vault provides dynamic secrets, lease management, and encryption as a service. Applications should authenticate with a workload-appropriate method such as Kubernetes auth, cloud IAM auth, or AppRole rather than a long-lived hard-coded token. Vault supports secret versioning, dynamic database credentials, and detailed audit logs. Spring Vault integration allows access through dependency injection, and rotation is centralized when the application handles lease renewal and refreshed values correctly.
Framework-Specific Guidance
Spring Boot
# SECURE - Spring Boot configuration
spring:
datasource:
url: ${DB_URL:jdbc:mysql://localhost:3306/mydb}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
aws:
credentials:
access-key: ${AWS_ACCESS_KEY}
secret-key: ${AWS_SECRET_KEY}
// Configuration class
@Configuration
public class DatabaseConfig {
@Value("${spring.datasource.url}")
private String dbUrl;
@Value("${spring.datasource.username}")
private String dbUsername;
@Value("${spring.datasource.password}")
private String dbPassword;
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(dbUrl);
config.setUsername(dbUsername);
config.setPassword(dbPassword);
config.setMaximumPoolSize(10);
return new HikariDataSource(config);
}
}
# NOT committed - add to .gitignore
DB_URL=jdbc:mysql://localhost:3306/mydb
DB_USERNAME=admin
DB_PASSWORD=<local development value, never a production one>
AWS_ACCESS_KEY=<local development value, never a production one>
AWS_SECRET_KEY=<local development value, never a production one>
# Use environment variables or an external config server in deployed environments.
# For production: export DB_PASSWORD=$(aws secretsmanager get-secret-value ...)
Java EE / Jakarta EE
// SECURE - JNDI DataSource (configured in application server)
@Resource(lookup = "java:comp/env/jdbc/MyDB")
private DataSource dataSource;
public void queryDatabase() throws SQLException {
try (Connection conn = dataSource.getConnection()) {
// Use connection
}
}
<Resource name="jdbc/MyDB"
auth="Container"
type="javax.sql.DataSource"
username="${DB_USERNAME}"
password="${DB_PASSWORD}"
driverClassName="com.mysql.cj.jdbc.Driver"
url="${DB_URL}"
maxTotal="20"
maxIdle="10"
maxWaitMillis="10000" />
${DB_PASSWORD} here is a Java system property, not an environment
variable. Tomcat's digester always enables SystemPropertySource and nothing
else, so unless you add the environment source the container substitutes
nothing and the DataSource is configured with the literal string
${DB_PASSWORD} - a connection failure with no obvious cause. To resolve
against the process environment (which is what a Kubernetes Secret or a
systemd EnvironmentFile gives you), set this in setenv.sh:
CATALINA_OPTS="$CATALINA_OPTS -Dorg.apache.tomcat.util.digester.PROPERTY_SOURCE=org.apache.tomcat.util.digester.EnvironmentPropertySource"
The alternative is to keep the system-property form and pass
-DDB_PASSWORD=... on the command line, which puts the secret in every ps
listing on the host. Prefer the environment source, or a JNDI realm the
application server resolves from its own credential store.
Micronaut
// SECURE - Micronaut configuration
import io.micronaut.context.annotation.Property;
import jakarta.inject.Singleton;
@Singleton
public class DatabaseService {
private final String dbUrl;
private final String dbUsername;
private final String dbPassword;
public DatabaseService(
@Property(name = "datasources.default.url") String dbUrl,
@Property(name = "datasources.default.username") String dbUsername,
@Property(name = "datasources.default.password") String dbPassword
) {
this.dbUrl = dbUrl;
this.dbUsername = dbUsername;
this.dbPassword = dbPassword;
}
}
datasources:
default:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
External Configuration Files
// SECURE - Load config from external file NOT in version control
import java.io.FileInputStream;
import java.util.Properties;
public class ConfigLoader {
public static Properties loadSecrets() {
Properties props = new Properties();
// Load from file outside project directory
String configPath = System.getProperty("config.path", "/etc/myapp/secrets.properties");
try (FileInputStream fis = new FileInputStream(configPath)) {
props.load(fis);
} catch (IOException e) {
throw new RuntimeException("Failed to load configuration", e);
}
return props;
}
}
// Usage:
Properties secrets = ConfigLoader.loadSecrets();
String dbPassword = secrets.getProperty("database.password");
// .gitignore - MUST include:
/*
secrets.properties
*.env
application-local.properties
*/
Encryption Keys Management
// SECURE - Key management with AWS KMS
import software.amazon.awssdk.services.kms.KmsClient;
import software.amazon.awssdk.services.kms.model.*;
import software.amazon.awssdk.core.SdkBytes;
public class EncryptionService {
private final KmsClient kmsClient;
private final String keyId;
public EncryptionService() {
this.kmsClient = KmsClient.create();
this.keyId = System.getenv("KMS_KEY_ID");
}
public byte[] encrypt(String plaintext) {
SdkBytes plaintextBytes = SdkBytes.fromUtf8String(plaintext);
EncryptRequest request = EncryptRequest.builder()
.keyId(keyId)
.plaintext(plaintextBytes)
.build();
EncryptResponse response = kmsClient.encrypt(request);
return response.ciphertextBlob().asByteArray();
}
public String decrypt(byte[] ciphertext) {
SdkBytes ciphertextBytes = SdkBytes.fromByteArray(ciphertext);
DecryptRequest request = DecryptRequest.builder()
.ciphertextBlob(ciphertextBytes)
.build();
DecryptResponse response = kmsClient.decrypt(request);
return response.plaintext().asUtf8String();
}
}
Testing with Test Credentials
Manual verification steps
Search for hard-coded credentials
# Search for hard-coded passwords
grep -ri "password.*=.*\"[^$]" src/ config/
grep -ri "secret.*=.*\"" src/
grep -ri "api[_-]key.*=.*\"" src/
# Search for JDBC URLs with credentials
grep -ri "jdbc:.*://.*:.*@" src/
# Search for AWS keys (pattern: AKIA...)
grep -ri "AKIA[0-9A-Z]\{16\}" src/
Verify environment variable usage
# Check application reads from environment
grep -r "System.getenv\|@Value.*\$" src/
# Verify .env files are in .gitignore
grep "\.env" .gitignore
Test with Testcontainers (for integration tests)
// Use Testcontainers for isolated test database
import org.testcontainers.containers.PostgreSQLContainer;
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:13")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass"); // Test-only, not production
postgres.start();
String jdbcUrl = postgres.getJdbcUrl();
// Use for testing - no production credentials needed
Check Git history for leaked credentials
# Search Git history for passwords (use tools like gitleaks, trufflehog)
gitleaks detect --source . --verbose
# Or manually search history
git log -p | grep -i "password\|secret\|api_key"
Verify configuration files
# Review application.properties, application.yml
grep -i "password\|secret" application.properties application.yml
# Should only see placeholders like ${DB_PASSWORD}
Automated verification
# Use secret scanning tools
gitleaks detect --source .
trufflehog git file://. --json
# SonarQube rule: S2068 (hard-coded credentials)
mvn sonar:sonar
.gitignore Best Practices
# Add these patterns to .gitignore
# Environment files
.env
.env.local
.env.*.local
# Configuration files with secrets
application-local.properties
application-local.yml
secrets.properties
config/secrets.yml
# IDE files (may contain credentials)
.idea/
*.iml
.vscode/
# AWS credentials
.aws/credentials
# Private keys
*.pem
*.key
*.p12
*.jks
Common Pitfalls
- Replacing
private static final String DB_PASSWORD = "..."with a value read via@Value("${db.password}"), but the referencedapplication.propertiesstill has the real password committed alongside the placeholder syntax - the indirection only helps if the committed file resolves to a value supplied at runtime rather than one written next to it. - Externalizing secrets to environment variables correctly for the main Spring Boot application, while a separate batch job, cron script, or integration test in the same repository still hard-codes the same database password because it never goes through Spring's configuration at all - the fix was applied to one entry point, not to every place the credential is used.
- Using AWS Secrets Manager or Vault to fetch a secret once at startup and caching it in a
staticfield indefinitely - this defeats rotation, since the application keeps using the old credential until a full restart, even though the secrets manager itself supports rotating the value. - Committing a
.envfile that is referenced bydocker-compose.ymlwith real production values, on the theory that.envfiles are "supposed to be" excluded from git - the convention only protects you if.gitignoreactually lists the file and it was never committed before that rule was added.