1. Hardcoding secrets breaks the security model
Configuration is the place where developers most often break security not out of malicious intent, but out of fatigue and the desire to “just make it work.” The most popular sin is to hardcode a secret, password, or token directly into Java code, and then wonder why security “suddenly” became the team’s problem rather than the problem of a specific class.
Imagine that we built the ideal JWT login and token validation (as in the module about stateless security), but the token signature is built from the string "super-secret-123", which sits right in TokenService. Formally, the application works. Practically, this is already an incident waiting to happen. As soon as the code ends up in the repository, the secret stops being a secret. Even if the repository is private, “private” does not mean “impossible to leak”: access is granted, layoffs happen, CI logs sometimes end up where they should not, and people are creative creatures anyway, especially when they are given access to Git.
This also includes “configuration by habit”: when, for convenience, you make CORS *, the token TTL 10 years, the avatar directory /tmp/uploads, and then it quietly turns into “that is just how it was.” So the main thesis of the lecture is simple: security is not only filters and annotations. Security is also about where the values that those filters use come from.
When access rules are already assembled and even covered by tests, the next class of failures does not come from controllers, but from runtime values around them. So next we will look not at new filters, but at how to avoid breaking an already working security model with secrets, TTL, and environment configs.
2. Externalized config in Spring Boot
Externalized config sounds like a term from the world of “architects on a high stool,” but the idea is very down to earth: code should contain stable decisions, while values should arrive from outside. You do not rewrite SecurityFilterChain every time the allowed origin changes. You change the config. You do not change TokenService when you want a different TTL — you change the config. And you definitely do not rebuild the jar just because prod needs a different secret.
In Spring Boot, configuration is a set of sources that are merged into one Environment. The sources can be different: application.yml, application-local.yml, environment variables, system properties, command-line arguments. An important thing for a beginner developer to understand: Boot does not “guess” what you meant. It deterministically chooses a value according to priority rules. Which means your job is to make sure this is predictable and does not turn into “it works on my machine, but not on the server.”
A small “how to think about it” diagram (simplified, without all the internal details):
flowchart TD
%% Code is stable; values come from external sources (application.yml, profiles, env vars).
A["Java code (stable logic)"] --> B["@Value / Environment / config binding"]
C["application.yml (baseline)"] --> D["Spring Environment"]
E["application-local.yml (override)"] --> D
F["ENV VARS (secrets/environment variables)"] --> D
D --> B
B --> A
The key separation here is this: SecurityFilterChain, 401/403 handlers, owner checks, and the role model are code, and they should be the same everywhere. But jwt.secret, access-token-ttl, allowed-origins, file storage paths, and logging levels are values that are legitimately different between environments. And if they change, that is not a reason to make a new commit in the code.
3. What we move into config in the project
Before touching YAML, it is useful to agree with yourself on which values should move from code into configuration. It is easy to go to extremes and start configuring everything, including role names and path patterns, but that will make the project not flexible, just confusing. We need the golden middle: move what really depends on the environment, and leave in code what is part of the architecture.
For our project, a reasonable minimum looks like this: JWT secret and token TTL, CORS allowed origins, the avatar directory (because on different machines those are different paths), as well as some “local operation” flags (for example, logging levels). Notice an important point: we are not moving “how security is built,” we are moving “which values it works with.”
Example of a basic application.yml (not a giant “300-line bedsheet,” but the actual base):
app:
security:
jwt:
secret: ${APP_SECURITY_JWT_SECRET} # secret comes only from outside: without it, startup must fail
access-token-ttl: 15m # human-readable duration: Spring Boot will convert it to Duration
cors:
allowed-origins: ${APP_SECURITY_CORS_ALLOWED_ORIGINS:http://localhost:3000} # default is safe for local development
storage:
avatar-dir: ${APP_STORAGE_AVATAR_DIR:./data/avatars} # path depends on the environment (but it is not a secret)
Here you can see three ideas. First, the secret is not written as a value — instead there is a placeholder ${APP_SECURITY_JWT_SECRET}. Second, the TTL is read as a human-readable 15m, and Boot knows how to convert that into Duration. Third, for non-sensitive values we can provide a default so that local startup does not turn into the quest “first configure 12 environment variables, then get coffee.”
To avoid getting lost in the keys, it helps to keep them in one style. We use a single prefix app.*, and inside it — app.security.* and app.storage.*. This is very helpful when you audit the project and want to quickly see: “aha, here is everything related to security.”
4. Placeholders ${...} and env vars
Placeholders in YAML are a simple but powerful thing: it is as if you are writing “the value will be like this, but take it from outside.” For a beginner, this may look like magic at the level of “why does YAML suddenly know about environment variables,” but technically it is just substitution via Spring’s property resolution. The important thing is to understand the two forms: ${VAR} and ${VAR:default}.
The ${VAR} form means: “the value must come from outside.” If the variable is missing, the application should fail at startup. And that is great for secrets, because “started quietly without a secret” is almost always worse than “failed loudly right away.” We prefer fail-fast: better a startup error than an unnoticed system running with the wrong security.
The ${VAR:default} form means: “if there is no external value, use the default.” This is convenient for local development and safe only where the default is truly safe and does not expose the system. For example, a default allowed origin for a local frontend is fine. A default JWT secret is a bad idea, because it turns a “secret” into a “hardcoded constant, just written not in Java, but in YAML.”
Here is a good approach (for a learning project) to a secret and “just settings”:
app:
security:
jwt:
secret: ${APP_SECURITY_JWT_SECRET} # no default: the secret is required, otherwise fail-fast
access-token-ttl: ${APP_SECURITY_JWT_TTL:15m} # default is possible, this is not "signature material"
Why can TTL have a default, but secret cannot? Because TTL is a behavior parameter, while secret is material for cryptographic signing. If you accidentally start with the wrong TTL, that is unpleasant. If you accidentally start with a “shared” secret, that is already a security threat: tokens can be forged, and your users suddenly become “too trusting.”
Another nuance: placeholders are also convenient for file paths. The path to the avatar directory depends on the machine, and sometimes on whether you are running the application in a container or on a laptop. That means it is definitely configuration, not a Java constant.
5. Profiles: local/test/prod without YAML copy-paste
Profiles are a way to tell the application: “we are in this environment now, so the values should be a little different.” The most common beginner mistake is to start copying the entire application.yml into application-local.yml, then into application-dev.yml, then into application-prod.yml, and a week later realize that the files have drifted apart and you are no longer sure which setting is where. Profiles are not for copy-paste, but for targeted overrides.
The right mental model is this: the base application.yml is “reasonable defaults” and placeholders. Profile-specific files change only what actually differs. For example, locally we want a different path for avatars and a softer CORS policy (because our frontend lives on localhost), and in tests we want predictable values so that CI does not require manual secret entry.
Example of application-local.yml that does not copy the world, but makes two neat overrides:
app:
storage:
avatar-dir: ./data/local-avatars # locally it can be stored next to the project
security:
cors:
allowed-origins: http://localhost:3000 # local frontend on localhost
Now, how do you enable the profile? The most convenient option for a beginner is the SPRING_PROFILES_ACTIVE environment variable. For example, on Linux/macOS:
export SPRING_PROFILES_ACTIVE=local # enable the profile so application-local.yml is picked up
./gradlew bootRun # start the application with the selected profile
For PowerShell on Windows:
$env:SPRING_PROFILES_ACTIVE="local" # enable the local development profile
./gradlew bootRun # start the application
You can (and often should) enable the profile from the IDE through a Run Configuration, but it is important to understand the principle itself: a profile is not a “separate application mode,” it is just a switch for configuration sources.
And one more practical thought: profiles are not only local/prod. In our course, the test profile is often useful, because tests need deterministic settings. But do not turn that into “in tests we always disable everything.” In tests, we usually want the opposite: security should be enabled, but the values should be controllable.
6. Env vars and secrets: storage without leaks
Env vars (environment variables) are essentially a “delivery channel” for values into the application without changing code and without committing to the repository. They are loved (both in DevOps and in Back-end) not because they are trendy, but because they are convenient and safer than storing secrets in files that can easily be committed by mistake. At the same time, env vars are not a magic safe: if you print them to logs, they become “a secret that everyone saw.” So the culture of handling secrets is still more important than the specific storage method.
For our project, the minimum set of env vars looks like this: APP_SECURITY_JWT_SECRET (the signing secret) and, possibly, APP_SECURITY_CORS_ALLOWED_ORIGINS and APP_STORAGE_AVATAR_DIR. Among them, the JWT secret is truly sensitive; the others are more like “environment settings.”
Example of how to set the secret and start the application locally (Linux/macOS):
export APP_SECURITY_JWT_SECRET="change-me-in-local-only" # local secret: DO NOT commit, DO NOT log
export SPRING_PROFILES_ACTIVE=local # enable the local profile
./gradlew bootRun # start the application
For PowerShell:
$env:APP_SECURITY_JWT_SECRET="change-me-in-local-only" # local secret: keep it out of the repository
$env:SPRING_PROFILES_ACTIVE="local" # enable the local profile
./gradlew bootRun # start the application
Now for the most painful moment: “where do we store this value so we do not have to enter it manually every time?” A typical educational compromise is to create a sample.env file (or application-local.example.yml) and not commit real values, but commit only the example. Keep the real values either in your OS environment variables, or in .env added to .gitignore. Just be careful not to confuse storage with delivery: .env is useful only when the shell, IDE, or Docker Compose actually exports its values into the process environment. By itself, Spring Boot does not read such a file as a standard source.
An important habit for a security developer: if you see a line like secret: "..." in a PR, your brain should turn on a siren. Not because “we are paranoid,” but because this is the cheapest and most common leak.
7. @Value: reading config and fail-fast
@Value("${...}") is not about security itself, but about wiring configuration into code. It simply tells Spring: “inject a value from the Environment here.” But this is exactly where you decide two important things: which keys the application really needs, and how the application should behave if a value is missing or wrong. For security parameters, a good style is fail-fast: better to fail at startup than to live “somehow.”
Let’s start with something simple and close to our project: TokenService, which signs JWTs and sets the lifetime. We want secret and ttl to come from configuration.
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class TokenService {
private final String secret; // secret is kept in service memory, but never logged
private final Duration ttl; // it is convenient to obtain Duration directly from "15m/30s/1h"
public TokenService(@Value("${app.security.jwt.secret}") String secret,
@Value("${app.security.jwt.access-token-ttl}") Duration ttl) {
// Here we simply "hook up" values from the Environment.
// Validation (fail-fast) is better done as a separate step below.
this.secret = secret;
this.ttl = ttl;
}
}
Notice a nice thing: Duration can be obtained directly if the YAML contains 15m, 30s, 1h. Boot performs the conversion itself. This reduces the amount of “manual parsing” and, oddly enough, reduces the chance of bugs in security (because there is less hand-written code that can be written incorrectly).
Now let’s add a fail-fast check. If the secret is empty or consists only of spaces, we do not want a “startup without a signature,” we want an explicit startup failure. In a learning project this is very useful: you immediately learn that you forgot the config.
import java.time.Duration;
import org.springframework.util.Assert;
public TokenService(String secret, Duration ttl) {
// hasText = not null and not empty/whitespace: for a secret this is a basic sanity check
Assert.hasText(secret, "JWT secret must be configured");
// TTL must also be set: otherwise token behavior will be random/broken
Assert.notNull(ttl, "JWT access token TTL must be configured");
// After the checks, store the values in the service fields
this.secret = secret;
this.ttl = ttl;
}
And one more important thing — logging. It is very tempting to write log.info("JWT secret: {}", secret), just to make sure it was “picked up.” Do not do that. If you really want to check it, log the fact, not the value. For example, the length of the secret or just true/false (but even length is sometimes unnecessary — the fewer traces of the secret, the better).
And yes: @Value is a convenient start, but it is not the “only correct” path. In larger projects, people often use more structured approaches (for example, grouping settings into a separate class). But within a fundamentals course, what matters more is discipline: the secret lives in config, keys are stable, values arrive from outside, and the application fails if the secret is not set.
8. Typical mistakes
Configuration mistakes rarely look like “our secret leaked.” More often they look like “why did my tests fail,” “why can’t the frontend reach it,” or “why is the token suddenly eternal.” So it is useful to know in advance which rakes are most common so you do not step on them with a happy face.
Mistake #1: hardcoding the secret in Java code “for a minute.”
The tricky part here is that “for a minute” easily turns into “for six months.” Then the secret is already in every environment, it ends up in a PR, it lives in git history, and even if you removed it — it was still published at some point. The fix here is not “delete the line,” but discipline: secrets only through externalized config, and for local development — through env vars or non-committed files.
Mistake #2: giving a default for the secret via ${VAR:default}.
At first glance this is convenient: the application always starts. At second glance, you have written a “universal secret” yourself and handed it to everyone who reads the repository. For a secret, it is better to “not start at all” than to start with a default. A default makes sense for allowed origins, directory paths, and other non-sensitive things, but not for token signing.
Mistake #3: copying the entire application.yml into application-local.yml and living in two realities.
At first it seems easier: “well, I just made a local version.” Then it turns out that you changed the TTL in one file, forgot it in the other, and the same project now lives with two different security models. A profile file should be small and targeted: override two or three values — and that is enough.
Mistake #4: committing real secrets into application-local.yml or .env.
The “random button rule” works here: you can be sure you will not commit them, but one day the IDE will show the file as modified, you will press “commit all,” and that is it. So either keep secrets only in env vars, or strictly ignore files with secrets through .gitignore, or commit only *.example.* versions without real values.
Mistake #5: logging secrets “for debugging.”
Logs are not a trash can; they are a copy of your life that then lives in files, aggregators, CI, tickets, and chats. If you once output the JWT secret to a log, it stopped being a secret, even if you later “deleted the log.” When debugging configuration, log the fact that it exists and the active profile, but not the value. And definitely do not print the Authorization header in full and do not output raw JWTs (the token also contains data that you should not always scatter across logs).
GO TO FULL VERSION