Hardcoding secrets in source code is how credentials leak and audits fail. The clean way to externalize an LLM endpoint credential is through a spring boot application.yml api key binding that reads from environment variables at startup, keeping the literal token out of your repo while remaining declarative.
Step 1: Lay out the configuration tree in application.yml
Start with a dedicated namespace. Don’t drop the key under spring.* unless you are using a Spring AI starter; for a hand-rolled integration, a top-level llm block is clearer and avoids property collisions with framework internals.
llm:
provider:
openai:
base-url: https://api.openai.com/v1
api-key: ${LLM_API_KEY:default-placeholder}
timeout-ms: 30000
model: gpt-4o-mini
The ${LLM_API_KEY:default-placeholder} syntax is Spring’s property placeholder. At startup, Spring resolves LLM_API_KEY from the environment, system properties, or a Vault backend. If the variable is unset, it falls back to the literal default-placeholder so the application still boots in local dev without a real token. Never commit a real key; the placeholder is safe to ship.
YAML is indentation-sensitive. Use two spaces, not tabs. A misplaced space turns api-key into a nested map and your binding silently fails. Spring loads application.yml from src/main/resources by default, but profile-specific files like application-prod.yml override the base with the same structure. The spring boot application.yml api key resolution order follows the standard externalized config precedence: env vars beat YAML, which beats defaults in code.
Step 2: Bind the properties with @ConfigurationProperties
Manual @Value("${llm.provider.openai.api-key}") injections scatter configuration across classes and give you no central validation. A single bound POJO gives type safety and lets Bean Validation run at startup.
package com.example.demo.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.NotBlank;
@Validated
@ConfigurationProperties(prefix = "llm.provider.openai")
public class OpenAiConfig {
@NotBlank
private String baseUrl;
@NotBlank
private String apiKey;
private int timeoutMs = 30000;
private String model = "gpt-4o-mini";
public String getBaseUrl() { return baseUrl; }
public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
public String getApiKey() { return apiKey; }
public void setApiKey(String apiKey) { this.apiKey = apiKey; }
public int getTimeoutMs() { return timeoutMs; }
public void setTimeoutMs(int timeoutMs) { this.timeoutMs = timeoutMs; }
public String getModel() { return model; }
public void setModel(String model) { this.model = model; }
}
Register it on a configuration class:
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(OpenAiConfig.class)
public class AppConfig {}
If LLM_API_KEY is missing and the placeholder default is used, @NotBlank will not trip because the placeholder resolves to a non-empty string. That is why Step 5 includes a check for the placeholder value specifically. If you want a hard failure on missing secrets, drop the :default-placeholder fallback and let Spring throw IllegalArgumentException during binding.
Step 3: Build an HTTP client that uses the key
Spring 6.1’s RestClient is the modern synchronous HTTP interface and replaces RestTemplate for new code. Construct it as a bean so the spring boot application.yml api key flows in exactly once.
import org.springframework.web.client.RestClient;
import java.time.Duration;
@Bean
public RestClient llmRestClient(OpenAiConfig config) {
return RestClient.builder()
.baseUrl(config.getBaseUrl())
.defaultHeader("Authorization", "Bearer " + config.getApiKey())
.defaultHeader("Content-Type", "application/json")
.requestFactory(new SimpleClientHttpRequestFactory() {{
setConnectTimeout(Duration.ofMillis(config.getTimeoutMs()));
setReadTimeout(Duration.ofMillis(config.getTimeoutMs()));
}})
.build();
}
A service that calls the chat endpoint:
@Service
public class ChatService {
private final RestClient client;
private final OpenAiConfig config;
public ChatService(RestClient llmRestClient, OpenAiConfig config) {
this.client = llmRestClient;
this.config = config;
}
public String ask(String prompt) {
var body = Map.of(
"model", config.getModel(),
"messages", List.of(Map.of("role", "user", "content", prompt))
);
return client.post()
.uri("/chat/completions")
.body(body)
.retrieve()
.body(String.class);
}
}
The header is set per request via the default header on the client. If you rotate the key, restart the app or implement a refresh mechanism; RestClient built this way is immutable. For async workloads, swap RestClient for WebClient and set the header in the headers filter—the property binding stays identical.
Step 4: Keep the spring boot application.yml api key out of version control
The application.yml in src/main/resources is for structure, not secrets. Put the real value in an environment file that is git-ignored, or inject it at the process manager level.
export LLM_API_KEY=sk-realkeyfromvault
./mvnw spring-boot:run
For local development, create application-local.yml and add it to .gitignore:
# application-local.yml (never committed)
llm:
provider:
openai:
api-key: ${LLM_API_KEY}
Activate the profile with -Dspring.profiles.active=local. In CI, inject the secret as a masked environment variable. Spring resolves ${LLM_API_KEY} from the process environment regardless of profile, so the spring boot application.yml api key binding stays identical across environments.
If you deploy with Docker, pass it at runtime:
docker run -e LLM_API_KEY=sk-realkey my-llm-service:1.0
In Kubernetes, mount the key as a Secret and set the env var in the deployment manifest. The YAML file itself contains zero sensitive bytes. This pattern also satisfies most compliance scanners that flag hardcoded sk- strings.
Step 5: Verify the wiring with a live call
A unit test that only checks null is weak. Write a @SpringBootTest that asserts the placeholder was replaced and optionally hits the endpoint with a short timeout.
@SpringBootTest
class LlmConfigVerificationTest {
@Autowired
OpenAiConfig config;
@Test
void apiKeyIsNotPlaceholder() {
assertFalse(config.getApiKey().contains("default-placeholder"),
"LLM_API_KEY env var was not set; config fell back to placeholder");
assertTrue(config.getApiKey().startsWith("sk-"));
}
@Test
void chatCallReturnsJson() {
var client = RestClient.builder()
.baseUrl(config.getBaseUrl())
.defaultHeader("Authorization", "Bearer " + config.getApiKey())
.build();
var resp = client.post()
.uri("/chat/completions")
.body(Map.of("model", config.getModel(),
"messages", List.of(Map.of("role","user","content","ping"))))
.retrieve()
.body(String.class);
assertNotNull(resp);
assertTrue(resp.contains("choices"));
}
}
Run with the env var set:
LLM_API_KEY=sk-testkey mvn test -Dtest=LlmConfigVerificationTest
For a manual smoke test against a running app, expose a temporary actuator endpoint or use curl directly with the same key:
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $LLM_API_KEY" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'
If the test fails because the key starts with default, you forgot to export the variable. That is the most common miss. A green apiKeyIsNotPlaceholder test in your pipeline is a cheap guard against accidental placeholder promotion to production.
Step 6: Route through a gateway for fallback
When you need multiple model vendors without rewriting clients, point the base-url at an OpenAI-compatible gateway. For example, n4n.ai exposes one endpoint that addresses 240+ models and performs automatic fallback when a provider is rate-limited; the same spring boot application.yml api key structure works by swapping the host and token.
llm:
provider:
openai:
base-url: https://api.n4n.ai/v1
api-key: ${LLM_API_KEY}
model: anthropic/claude-3.5-sonnet
The client code from Step 3 does not change. You only reconfigure the YAML and supply the gateway token. This keeps your service agnostic to backend model availability and lets you honor provider cache-control hints forwarded by the gateway without touching Java code.
Troubleshooting and gotchas
Relaxed binding means api-key, apiKey, and API_KEY all map to the same property, but the YAML key must match the prefix exactly. If llm.provider.openai.api-key is not picked up, check for a typo in the prefix string on @ConfigurationProperties.
If you see Could not resolve placeholder 'LLM_API_KEY', you omitted the :default-placeholder fallback and the env var is unset. Either add the fallback or export the variable before boot.
Finally, never log the full key. Add a toString() exclusion or use @JsonIgnore on the field if the config object is serialized. The apiKey string in memory is still a secret; restrict who can dump the heap. Following these steps gives you a reproducible, environment-driven spring boot application.yml api key setup that passes security review and survives provider swaps.