Skip to content

CWE-522: Insufficiently Protected Credentials - Java

Overview

Insufficiently Protected Credentials in Java applications occurs when passwords, API keys, secret tokens, or other authentication credentials are stored in plaintext, weakly encrypted, hardcoded in source code, checked into version control, or transmitted insecurely. Java provides cryptographic libraries through the JCA (Java Cryptography Architecture), but the choice and configuration of a password hashing algorithm - BCrypt, PBKDF2, or Argon2 - is the developer's.

Primary Defence: Use BCrypt (Spring Security BCryptPasswordEncoder), Argon2, or PBKDF2 for password hashing with appropriate cost factors, and never store passwords in plaintext.

Common Vulnerable Patterns

Storing Passwords in Plaintext

// VULNERABLE - Plaintext password storage
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String username;

    @Column(nullable = false)
    private String password;  // Plaintext password field!

    // Getters and setters
}

@RestController
public class AuthController {
    @Autowired
    private UserRepository userRepository;

    @PostMapping("/register")
    public ResponseEntity<?> register(@RequestBody RegistrationRequest request) {
        User user = new User();
        user.setUsername(request.getUsername());
        // Storing password directly without hashing!
        user.setPassword(request.getPassword());

        userRepository.save(user);
        return ResponseEntity.ok().build();
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request) {
        User user = userRepository.findByUsername(request.getUsername());

        // Direct password comparison!
        if (user != null && user.getPassword().equals(request.getPassword())) {
            return ResponseEntity.ok(generateToken(user));
        }

        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }
}

Why this is vulnerable:

  • Anyone with database access (DBAs, backups, SQL injection, insiders) can read user passwords directly.
  • Password reuse means a single breach can compromise accounts elsewhere.

Hardcoded Credentials

// VULNERABLE - Credentials hardcoded in source
public class DatabaseConfig {
    // Hardcoded database credentials!
    private static final String DB_URL = "jdbc:postgresql://prod-db.company.com:5432/mydb";
    private static final String DB_USER = "admin";
    private static final String DB_PASSWORD = "SuperSecret123!";  // NEVER DO THIS!

    public Connection getConnection() throws SQLException {
        return DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
    }
}

@Configuration
public class ApiConfig {
    // Hardcoded API keys!
    public static final String API_KEY = "sk-live-abc123def456ghi789";
    public static final String SECRET_KEY = "my-secret-key-12345";
}

Why this is vulnerable:

  • Credentials are exposed to anyone with repo access and can leak via artifacts or decompiled bytecode.
  • Secrets linger in git history and require code changes to rotate.

Hardcoded API Keys in HTTP Clients

// VULNERABLE - Hardcoded API Keys in HTTP Clients
@Configuration
public class HttpClientConfig {

    private static final String API_KEY = "sk-live-abc123def456ghi789";

    @Bean
    public RestTemplate restTemplate() {
        RestTemplate template = new RestTemplate();
        template.getInterceptors().add((request, body, execution) -> {
            // Hardcoded API key in interceptor
            request.getHeaders().add("Authorization", "Bearer " + API_KEY);
            return execution.execute(request, body);
        });
        return template;
    }
}

Why this is vulnerable: A literal in source is a literal in the compiled artifact, so the key travels wherever the jar does and strings recovers it without decompiling anything. It is also in every clone of the repository and in the history, which is what makes rotation the real cost: removing the line does not remove the value from commits already pushed, so the credential has to be revoked rather than deleted.

The operational consequence drives the fix. A hardcoded key cannot be rotated without a rebuild and a redeploy, so it tends not to be rotated at all - which is why the answer is an injected value from an environment variable or a secrets manager, not a better-hidden constant. An environment variable injected by the deployment platform is a genuine improvement on a constant, but it only moves rotation from a rebuild to a restart - a value fetched from a secrets manager at runtime needs neither, and CWE-526 covers what else a secret left sitting in the process environment is exposed to.

Weak Password Hashing (MD5/SHA-1)

// VULNERABLE - Using broken hash algorithms
import java.security.MessageDigest;
import java.util.Base64;

@Service
public class WeakAuthService {

    public String hashPassword(String password) throws Exception {
        // MD5 is cryptographically broken!
        MessageDigest md = MessageDigest.getInstance("MD5");
        byte[] hash = md.digest(password.getBytes());
        return Base64.getEncoder().encodeToString(hash);
    }

    public boolean verifyPassword(String password, String hash) throws Exception {
        // Even with comparison, MD5 is too weak
        return hashPassword(password).equals(hash);
    }
}

// Also vulnerable with SHA-1
@Service
public class WeakAuthServiceSha1 {

    public String hashPasswordSHA1(String password) throws Exception {
        MessageDigest md = MessageDigest.getInstance("SHA-1");
        byte[] hash = md.digest(password.getBytes());
        return Base64.getEncoder().encodeToString(hash);
    }
}

Why this is vulnerable:

  • MD5 and SHA-1 are fast and broken, making brute-force practical with GPUs.
  • No built-in salt or work factor makes large-scale cracking cheap.

Secrets Stored in a Committed Properties File

# VULNERABLE - plaintext secrets in a file that is committed to version control
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.datasource.username=admin
spring.datasource.password=password123
jwt.secret=my-super-secret-jwt-key
api.key=sk-live-abc123def456
aws.access.key=AKIAIOSFODNN7EXAMPLE
aws.secret.key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
// The injection itself is ordinary Spring; what makes it vulnerable is that
// the values come from the committed properties file above
@Configuration
public class AppConfig {
    @Value("${spring.datasource.password}")
    private String dbPassword;  // Exposed if properties in git

    @Value("${jwt.secret}")
    private String jwtSecret;  // Too simple and in version control
}

Why this is vulnerable: The file is in version control, so the secret is on every developer machine, every build agent and every fork, and adding it to .gitignore later removes it from the working tree and not from history.

Packaging makes the exposure wider than the repository. A properties file under src/main/resources is copied into the jar, so it ships to anyone who receives the artifact. Spring Boot reads the same keys from environment variables and from a mounted config, so the migration is a deployment change rather than a code change - and the committed file should be replaced by one holding placeholders, so the shape stays documented without the values.

Insecure JWT Implementation

// VULNERABLE - Weak JWT secret and long expiration
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

@Service
public class InsecureJwtService {
    // Weak, hardcoded secret!
    private static final String SECRET_KEY = "secret";
    // Says a year and is not one: every operand is an int, so the product overflows
    // to 1471228928 before it ever widens to long. That is 17 days, not 365. Add an
    // L to the first operand (365L * 24 * ...) to get the value the comment claims.
    private static final long EXPIRATION_TIME = 365 * 24 * 60 * 60 * 1000; // 1 year!

    public String generateToken(User user) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + EXPIRATION_TIME);

        return Jwts.builder()
                .setSubject(user.getUsername())
                // Including password hash in token!
                .claim("password", user.getPasswordHash())
                .claim("role", user.getRole())
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                // Weak secret makes tokens easy to forge
                .signWith(SignatureAlgorithm.HS512, SECRET_KEY)
                .compact();
    }
}

Why this is vulnerable:

  • Weak, hardcoded secrets make tokens forgeable and hard to rotate.
  • Long expirations and sensitive claims increase damage if tokens leak.
  • The password hash sits in the payload, and a JWT payload is base64url of plaintext, not ciphertext - every client holding the token can read it, which moves cracking offline for anyone who collects one from a log or a proxy.

One detail is worth knowing before reaching for this as a reproduction. This overload reads SECRET_KEY as base64, so "secret" decodes to four bytes, and current jjwt refuses to sign with an HMAC key below the algorithm's hash size - verified on 0.13.0, where the call raises WeakKeyException instead of minting a forgeable token. The library closed that particular door while leaving the shape intact, and the shape is what still needs fixing: a short literal secret in source signs perfectly well through a hand-rolled Mac, an older library, or HS256 with a key that clears the length floor and is still in the wordlist.

HTTP Credential Transmission

// VULNERABLE - No HTTPS enforcement
@Configuration
public class WebSecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            )
            .formLogin(Customizer.withDefaults());

        // No requiresChannel() - the form login above accepts credentials over HTTP!
        return http.build();
    }
}

// Application running on HTTP

Why this is vulnerable:

  • HTTP sends credentials in cleartext.
  • Anyone on the path (WiFi sniffers, ISPs, compromised routers) can intercept them.

Secure Patterns

BCrypt Password Encoding with Spring Security

// SECURE - Proper BCrypt implementation
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        // BCrypt with strength 12 (recommended minimum)
        return new BCryptPasswordEncoder(12);
    }
}

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String username;

    @Column(nullable = false)
    private String passwordHash;  // Hashed password

    // No plain password field!
}

@Service
public class UserService {

    // A real BCrypt hash at the same strength as the encoder above. Verifying against
    // it costs what a real verification costs, so an unknown username takes as long
    // as a known one. It must be a genuine hash - matches() against "" or null
    // returns immediately and restores the timing difference it was added to remove.
    private static final String DUMMY_HASH =
            "$2a$12$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG";

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    public User registerUser(String username, String password) {
        User user = new User();
        user.setUsername(username);

        // Hash password with BCrypt (auto-salted)
        String passwordHash = passwordEncoder.encode(password);
        user.setPasswordHash(passwordHash);

        return userRepository.save(user);
    }

    public boolean verifyPassword(String username, String password) {
        User user = userRepository.findByUsername(username);

        if (user == null) {
            // Spend the same work on an unknown user before failing. Returning here
            // without hashing answers in microseconds where a real user costs ~250ms,
            // and that difference enumerates every username in the database.
            passwordEncoder.matches(password, DUMMY_HASH);
            return false;
        }

        // BCrypt handles salt automatically
        return passwordEncoder.matches(password, user.getPasswordHash());
    }
}

Why this works:

  • BCrypt uses per-password salts and a tunable work factor, making offline cracking expensive.
  • Spring Security's PasswordEncoder handles hashing and verification with standard, portable BCrypt hashes.
  • Verifying an unknown username against a dummy hash removes the response-time difference that otherwise enumerates accounts. The endpoint now pays the full hashing cost on every request, so rate-limit it.

Spring Vault Integration

// SECURE - HashiCorp Vault integration
import org.springframework.vault.core.VaultKeyValueOperations;
import org.springframework.vault.core.VaultKeyValueOperationsSupport.KeyValueBackend;
import org.springframework.vault.core.VaultTemplate;
import org.springframework.vault.support.VaultResponse;

@Configuration
@EnableVaultRepositories
public class VaultConfig extends AbstractVaultConfiguration {

    @Override
    public VaultEndpoint vaultEndpoint() {
        VaultEndpoint endpoint = new VaultEndpoint();
        endpoint.setHost(System.getenv("VAULT_HOST"));
        endpoint.setPort(Integer.parseInt(System.getenv("VAULT_PORT")));
        endpoint.setScheme("https");
        return endpoint;
    }

    @Override
    public ClientAuthentication clientAuthentication() {
        // Use token authentication (token from environment)
        return new TokenAuthentication(System.getenv("VAULT_TOKEN"));
    }
}

@Service
public class SecretService {

    private final VaultKeyValueOperations kv;

    // KV v2 wraps the secret one level deeper than KV v1: the HTTP response is
    // {"data": {"data": {...}, "metadata": {...}}}, and VaultTemplate.read() returns
    // the outer node - so read("secret/data/x").getData().get("password") is null.
    // opsForKeyValue(..., versioned()) unwraps it and takes the logical path, with
    // no "data/" segment. Use unversioned() for a KV v1 mount.
    @Autowired
    public SecretService(VaultTemplate vaultTemplate) {
        this.kv = vaultTemplate.opsForKeyValue("secret", KeyValueBackend.versioned());
    }

    public Map<String, Object> getDatabaseCredentials() {
        return required("database/postgresql").getData();
    }

    public String getApiKey(String serviceName) {
        return (String) required("api-keys/" + serviceName).getData().get("key");
    }

    // Single-value lookup, used by JwtService below
    public String getSecret(String path) {
        return (String) required(path).getData().get("value");
    }

    private VaultResponse required(String path) {
        VaultResponse response = kv.get(path);
        if (response == null || response.getData() == null) {
            throw new IllegalStateException("No secret at " + path);
        }
        return response;
    }
}

@Configuration
public class DataSourceConfig {

    @Autowired
    private SecretService secretService;

    @Bean
    public DataSource dataSource() {
        Map<String, Object> dbCreds = secretService.getDatabaseCredentials();

        HikariConfig config = new HikariConfig();
        config.setJdbcUrl((String) dbCreds.get("url"));
        config.setUsername((String) dbCreds.get("username"));
        config.setPassword((String) dbCreds.get("password"));

        return new HikariDataSource(config);
    }
}

Why this works:

  • Vault keeps secrets out of code and config, with encryption at rest and audit logging.
  • Token auth + Vault policies enable least-privilege access and support rotation.
  • Reading through opsForKeyValue rather than VaultTemplate.read() is what makes the values arrive. Against a KV v2 mount the raw read returns the envelope, not the secret, and every lookup silently yields null - a failure that surfaces at the first use of the credential rather than at the read.

AWS Secrets Manager Integration

// 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.ObjectMapper;

@Service
public class AWSSecretsService {

    private final SecretsManagerClient client;
    private final ObjectMapper objectMapper;

    public AWSSecretsService() {
        this.client = SecretsManagerClient.builder()
                .region(Region.US_EAST_1)
                .build();
        this.objectMapper = new ObjectMapper();
    }

    public Map<String, String> getSecret(String secretName) {
        GetSecretValueRequest request = GetSecretValueRequest.builder()
                .secretId(secretName)
                .build();

        GetSecretValueResponse response = client.getSecretValue(request);
        String secretString = response.secretString();

        try {
            return objectMapper.readValue(secretString, Map.class);
        } catch (Exception e) {
            throw new RuntimeException("Failed to parse secret", e);
        }
    }
}

@Configuration
public class DatabaseConfig {

    @Autowired
    private AWSSecretsService secretsService;

    @Bean
    public DataSource dataSource() {
        Map<String, String> dbSecret = secretsService.getSecret("prod/database");

        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(dbSecret.get("url"));
        config.setUsername(dbSecret.get("username"));
        config.setPassword(dbSecret.get("password"));
        config.setMaximumPoolSize(10);

        return new HikariDataSource(config);
    }
}

Why this works:

  • Secrets are encrypted with KMS and fetched at runtime using IAM roles, so no embedded credentials.
  • Built-in rotation, versioning, and CloudTrail logging reduce exposure and aid auditing.

Secure JWT Implementation

<!-- jjwt 0.12 renamed most of the fluent API and 0.13 keeps that shape, so the
     code below compiles unchanged on either. Code written against 0.11 does not:
     Jwts.parserBuilder() was removed outright and is still absent in 0.13.0. -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.13.0</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.13.0</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.13.0</version>
    <scope>runtime</scope>
</dependency>
// SECURE - Proper JWT with strong secret (jjwt 0.12+ API)
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;

@Service
public class JwtService {

    // Separate keys per token class - a refresh token signed with the refresh key
    // cannot be presented as an access token, whatever its claims say
    private final SecretKey accessKey;
    private final SecretKey refreshKey;
    private static final long ACCESS_TOKEN_VALIDITY = 3600000; // 1 hour
    private static final long REFRESH_TOKEN_VALIDITY = 2592000000L; // 30 days
    private static final String ISSUER = "https://api.example.com";
    private static final String AUDIENCE = "example-app";

    @Autowired
    public JwtService(SecretService secretService) {
        // Load secrets from secure storage, not hardcoded
        this.accessKey = loadKey(secretService.getSecret("jwt/access-key"));
        this.refreshKey = loadKey(secretService.getSecret("jwt/refresh-key"));
    }

    private static SecretKey loadKey(String base64Secret) {
        // hmacShaKeyFor throws WeakKeyException below 256 bits, and signWith below
        // rejects anything under 512 for HS512. Both measure the decoded key, not
        // the length of the base64 string that carried it.
        return Keys.hmacShaKeyFor(Base64.getDecoder().decode(base64Secret));
    }

    // Method to generate strong secret key (run once, store in vault)
    public static String generateSecretKey() {
        SecretKey key = Jwts.SIG.HS512.key().build();
        return Base64.getEncoder().encodeToString(key.getEncoded());
    }

    public String createAccessToken(String username, String role) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + ACCESS_TOKEN_VALIDITY);

        return Jwts.builder()
                .subject(username)
                .issuer(ISSUER)
                .audience().add(AUDIENCE).and()
                .id(UUID.randomUUID().toString())
                .claim("role", role)
                .claim("type", "access")
                // Don't include sensitive data!
                .issuedAt(now)
                .expiration(expiryDate)
                .signWith(accessKey, Jwts.SIG.HS512)
                .compact();
    }

    public String createRefreshToken(String username) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + REFRESH_TOKEN_VALIDITY);

        return Jwts.builder()
                .subject(username)
                .issuer(ISSUER)
                .audience().add(AUDIENCE).and()
                .id(UUID.randomUUID().toString())
                .claim("type", "refresh")
                .issuedAt(now)
                .expiration(expiryDate)
                .signWith(refreshKey, Jwts.SIG.HS512)
                .compact();
    }

    // Request filters call this one; only the /token/refresh endpoint calls the other
    public Claims validateAccessToken(String token) {
        return parse(token, accessKey, "access");
    }

    public Claims validateRefreshToken(String token) {
        return parse(token, refreshKey, "refresh");
    }

    private Claims parse(String token, SecretKey key, String expectedType) {
        try {
            return Jwts.parser()
                    .verifyWith(key)
                    .requireIssuer(ISSUER)
                    .requireAudience(AUDIENCE)
                    .require("type", expectedType)
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();
        } catch (ExpiredJwtException e) {
            throw new TokenExpiredException("Token has expired");
        } catch (JwtException e) {
            throw new InvalidTokenException("Invalid token");
        }
    }
}

Why this works:

  • Strong, vault-stored HMAC keys and short-lived access tokens reduce compromise impact.
  • Issuer/audience validation narrows what a token is accepted for. jti is what a revocation list would key on - it does not revoke anything by itself, so tokens remain valid until they expire unless a deny list is built and consulted here.
  • Access and refresh tokens are signed with different keys and carry a type claim that the parser requires, so a 30-day refresh token presented to a request filter fails signature verification rather than authenticating the request for the next month. One key with only the type check would also work, but then the check is the only thing standing between the two token classes - if a new call site forgets it, the refresh token verifies cleanly.

HTTPS Enforcement

// SECURE - Force HTTPS in Spring Boot
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // Require HTTPS for all requests
            .requiresChannel(channel -> channel
                .anyRequest().requiresSecure()
            )
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            )
            .formLogin(Customizer.withDefaults());

        return http.build();
    }
}

// application.properties for production
# server.port=8443
# server.ssl.key-store=classpath:keystore.p12
# server.ssl.key-store-password=${SSL_KEY_STORE_PASSWORD}
# server.ssl.key-store-type=PKCS12
# server.ssl.key-alias=tomcat

// Redirect HTTP to HTTPS
@Configuration
public class HttpsRedirectConfig {

    @Bean
    public ServletWebServerFactory servletContainer() {
        TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory() {
            @Override
            protected void postProcessContext(Context context) {
                SecurityConstraint securityConstraint = new SecurityConstraint();
                securityConstraint.setUserConstraint("CONFIDENTIAL");
                SecurityCollection collection = new SecurityCollection();
                collection.addPattern("/*");
                securityConstraint.addCollection(collection);
                context.addConstraint(securityConstraint);
            }
        };

        tomcat.addAdditionalTomcatConnectors(createHttpConnector());
        return tomcat;
    }

    private Connector createHttpConnector() {
        Connector connector = new Connector(TomcatServletWebServerFactory.DEFAULT_PROTOCOL);
        connector.setScheme("http");
        connector.setPort(8080);
        connector.setSecure(false);
        connector.setRedirectPort(8443);
        return connector;
    }
}

Why this works:

  • HTTPS encrypts credentials in transit and prevents cleartext submission.
  • Enforced redirects and container-level constraints ensure HTTP is not accepted.

Argon2 Password Encoding

<!-- Spring Security's Argon2PasswordEncoder delegates to BouncyCastle and does not
     bring it in transitively. Without this dependency the bean constructs fine and
     the first encode() call throws NoClassDefFoundError:
     org/bouncycastle/crypto/params/Argon2Parameters$Builder -->
<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcprov-jdk18on</artifactId>
    <version>1.85.2</version>
</dependency>
// SECURE - Argon2 (most secure option)
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;

@Configuration
public class Argon2Config {

    @Bean
    public PasswordEncoder passwordEncoder() {
        // Argon2id with parameters above the OWASP floor of 19 MiB / 2 iterations.
        // Constructor argument order is salt, hash, parallelism, memory, iterations -
        // memory and iterations are adjacent and easy to transpose, which silently
        // gives you 3 KiB and 65536 passes instead.
        return new Argon2PasswordEncoder(
            16,    // salt length (bytes)
            32,    // hash length (bytes)
            1,     // parallelism
            65536, // memory (KiB, so 64 MiB)
            3      // iterations
        );
    }
}

Why this works:

  • Argon2 is memory-hard and resists GPU/ASIC cracking better than legacy hashes.
  • Tunable parameters and PHC-formatted hashes support future upgrades.
  • A memory cost measured in kibibytes is what makes GPU cracking expensive, because each parallel guess needs its own 64 MiB. That same cost is paid on every login, so raise it until verification latency is at the edge of acceptable and no further.

Environment-Based Configuration

// SECURE - External configuration
// application.properties (safe defaults, no secrets)
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
jwt.secret=${JWT_SECRET}

// Deployment configuration (not in code)
// Set via environment variables, Kubernetes secrets, or AWS Parameter Store
# export DB_URL=jdbc:postgresql://localhost:5432/mydb
# export DB_USERNAME=appuser
# export DB_PASSWORD=<from-secrets-manager>
# export JWT_SECRET=<from-vault>

@Configuration
public class AppConfig {

    @Value("${jwt.secret}")
    private String jwtSecret;  // Loaded from environment at runtime

    // Validation on startup. Measure the decoded key, not the string: 32 base64
    // characters decode to 24 bytes, and Keys.hmacShaKeyFor() rejects anything under
    // 256 bits outright - so a "valid" secret by a character count would fail at the
    // first signature. HS512 needs 512 bits, which is 64 bytes or 88 base64 characters.
    private static final int MIN_KEY_BYTES = 64;  // HS512

    @PostConstruct
    public void validateConfig() {
        if (jwtSecret == null) {
            throw new IllegalStateException("jwt.secret is not configured");
        }
        int keyBytes = Base64.getDecoder().decode(jwtSecret).length;
        if (keyBytes < MIN_KEY_BYTES) {
            throw new IllegalStateException(
                "JWT secret decodes to " + keyBytes + " bytes; HS512 requires " + MIN_KEY_BYTES);
        }
    }
}

Why this works:

  • Secrets stay out of code and repos by using environment-injected values.
  • Deployment secret stores supply those values, and the startup check fails the boot when the secret is missing or decodes below the length HS512 needs, rather than at the first signature.

Testing

To verify credentials are protected:

  • Check password storage: Values in the database start with $2a$, $2b$, or $argon2, never the password itself
  • Confirm salt usage: Same passwords for different users should produce different hashes
  • Verify configuration: application.properties, application.yml, and other config files reference environment variables or secret placeholders such as ${DB_PASSWORD}, not literal values
  • Test JWT expiration: Tokens should have reasonable expiration times (typically 15-60 minutes for access tokens)
  • Review source code: Search for hardcoded secrets, API keys, passwords, or connection strings
  • Test authentication: Login succeeds with correct credentials and fails with incorrect ones
  • Scan with tools: Static analysis should flag hardcoded credentials and weak hashing algorithms

Additional Resources