1. Introduction
If you are building a stateless login flow for the first time, it is very tempting to “just make it work” and put the signing secret directly into the JwtTokenService class as a "my-secret" string. It will work... right up until the first moment when you realize the secret has already gone into the repository, into Git history, and possibly into someone’s screenshot. And then it turns out that TTL is not a constant either: today you want 15 minutes, tomorrow — 5 minutes, and in tests — 2 seconds, so you do not have to wait forever.
In Spring Boot, the normal engineering model is this: code describes behavior, and the environment (local/dev/test/prod) supplies values through configuration. JWT secret and TTL are classic security settings. They should not live in the controller, should not be “magic numbers” spread through the code, and definitely should not be “the same forever” for every environment.
The most practical idea is this: the same jar should run in different conditions, and the only thing that changes is the config. Then you are not “rebuilding security” — you are just configuring it.
2. JWT in application.yml
When a beginner first hears “move the secret into config,” they often take it literally: “okay, I’ll write the secret in application.yml and commit it.” And here we make a friendly stop: application.yml is not a trash can for secrets, it is a place for configuration keys. The secret as a value should most often come from an environment variable, and YAML should remain either without a value or with a local default for development (and even that should be done carefully).
In our Secure Content Platform API project, we fix two keys:
| Key | Meaning | Example |
|---|---|---|
| app.security.jwt.secret | JWT signing secret | comes from env var |
| app.security.jwt.access-token-ttl | access token lifetime | for example 900000 (ms) |
Minimal application.yml example
First, let us show the most straightforward option, understandable even if you hate YAML (that happens; I do not judge — I hated it too at first).
# src/main/resources/application.yml
app:
security:
jwt:
# It is better to pass the value through env vars and leave only the "wiring" here
secret: ${APP_SECURITY_JWT_SECRET}
# TTL in milliseconds (15 minutes)
access-token-ttl: 900000
The important point here is that ${APP_SECURITY_JWT_SECRET} means “take the value from the environment variable.” If the variable is missing, the application will fail to start — and that is normal. A secret should not work without a secret.
If you want it to start locally “without gymnastics,” you can provide a default value with a colon. But do it intentionally and do not use a “production” secret.
app:
security:
jwt:
secret: ${APP_SECURITY_JWT_SECRET:dev-only-secret-change-me}
access-token-ttl: 900000
If you see the secret "dev-only-secret-change-me" in the repository, that is still acceptable for a learning project because it is not a real secret. But if you see a real 64-character string there that someone “generated and forgot,” that is already a warning sign.
TTL units: milliseconds and seconds
TTL is where very stupid and very expensive mistakes begin. The most common one is mixing up units. You think TTL is in seconds and set 900, but the code treats it as milliseconds — and the token lives for 0.9 seconds. Or the other way around: you think TTL is in milliseconds and set 900000, but the code treats it as seconds — and the token lives for about 10 days. At that point, security starts to “smile and wave” from far away.
To avoid guessing, we fix a rule in the project: app.security.jwt.access-token-ttl is stored in milliseconds. It is a little “ugly” in YAML, but mathematically simple: expiresAt = issuedAt + ttlMillis.
If seeing 900000 hurts your feelings, you can add a comment next to it (yes, comments in config are normal):
app:
security:
jwt:
access-token-ttl: 900000 # 15 minutes
The main thing is consistency. When everyone on the team understands “TTL in ms,” you eliminate an entire class of bugs.
3. DI: secret and TTL in TokenService
Now we have keys in the configuration, and the logical next step is to learn how to get them into Spring beans. Many people do this “on the fields,” literally:
@Value("${app.security.jwt.secret}")
private String secret;
It works, but it is not the nicest style. It is acceptable for a learning project, but as an engineering habit it is better to train yourself to use constructor injection right away. It makes dependencies explicit, helps testing, and leaves no chance of getting null if someone accidentally created the object outside Spring.
JwtTokenService: reading parameters from configuration
Below is a clean constructor-based version with @Value. We store parameters in final fields so they do not “drift” at runtime and so the class is easier to read.
package com.example.securecontent.security.jwt;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class JwtTokenService implements TokenService {
// JWT signing secret. We do not store it in code; we get it from configuration.
private final String secret;
// TTL is specifically in milliseconds — it is important to reflect that in the name.
private final long accessTokenTtlMillis;
// We build the claim set separately so JwtTokenService does not grow into an "everything machine".
private final JwtClaimsFactory claimsFactory;
public JwtTokenService(
// Take secret from application.yml / env var by key.
@Value("${app.security.jwt.secret}") String secret,
// Take TTL from configuration as the single source of truth.
@Value("${app.security.jwt.access-token-ttl}") long accessTokenTtlMillis,
JwtClaimsFactory claimsFactory) {
this.secret = secret;
this.accessTokenTtlMillis = accessTokenTtlMillis;
this.claimsFactory = claimsFactory;
}
}
Pay attention to a small detail that later saves hours: the names of parameters and fields clearly say that our TTL is in milliseconds. This is not fussiness. It is a “safety net against your future self.”
Small configuration check at startup
When the secret is missing or too short, it is better to get a clear error at application startup than strange 500s somewhere during login. To do that, you can add a simple validation, for example in @PostConstruct.
package com.example.securecontent.security.jwt;
import jakarta.annotation.PostConstruct;
// This method can be added directly to JwtTokenService so bad config is caught at startup.
@PostConstruct
void validateConfig() {
// Validate secret explicitly: better fail at startup than catch 500 during login.
if (secret == null || secret.isBlank()) {
throw new IllegalStateException("JWT secret is not configured");
}
}
Yes, it looks a bit like a guard at the entrance. But a guard at the entrance is exactly what we want here.
Using TTL when issuing IssuedAccessToken
At this point we still have infrastructure pieces. The most important place is the actual issueAccessToken(...), where the authenticated user, the selected claim set, the secret, and the configured TTL come together.
import java.time.Instant;
import java.util.Map;
import org.springframework.security.core.Authentication;
@Override
public IssuedAccessToken issueAccessToken(Authentication authentication) {
Instant now = Instant.now();
Instant exp = now.plusMillis(accessTokenTtlMillis);
// Claims are already assembled in one place: sub, userId, authorities, iat, and exp.
Map<String, Object> claims = claimsFactory.createClaims(authentication, now, exp);
// The specific JWT library can vary; the meaning of the step does not.
String jwt = sign(claims, secret);
long expiresInSeconds = accessTokenTtlMillis / 1000;
return new IssuedAccessToken(jwt, expiresInSeconds);
}
private String sign(Map<String, Object> claims, String secret) {
// This is where a specific JWT library and signing algorithm are chosen.
// For this part, the important thing is different: a ready-made claim set and secret go in,
// and a signed token string comes out.
return "...signed-jwt...";
}
This is where all the pieces of the day finally come together: AuthenticationManager has already returned an authenticated principal, JwtClaimsFactory has assembled the claim set, secret and TTL came from configuration, and an internal IssuedAccessToken went out. This separation is important: exp stays inside the JWT for the server, while expiresInSeconds goes outward so the client understands the response lifetime.
4. Full stateless login flow
Now this flow can be read more briefly: what becomes new at this stage are precisely the internals of JwtTokenService, and the rest of the chain should already look familiar. The right goal here is for the login flow to read like a recipe: “took DTO → authenticated → issued token → returned response.”
To solidify the picture, it is helpful to look at the diagram first. Not because we love diagrams, but because sometimes the brain finds it easier to see the route first and then read the code.
flowchart TD
Client[API client] -->|POST /api/auth/login| C[AuthController]
C --> S[AuthService]
S --> AM[AuthenticationManager]
AM -->|authenticated Authentication| S
S --> TS[TokenService]
TS -->|IssuedAccessToken| S
S -->|AuthResponse| C
C --> Client
AuthService.login(...): one scenario
Below is that “linear” method you want to read top to bottom. It does three things: turns the LoginRequest into an Authentication request, sends it to AuthenticationManager, and only after success asks TokenService to issue a token.
package com.example.securecontent.auth;
import com.example.securecontent.security.jwt.IssuedAccessToken;
import com.example.securecontent.security.jwt.TokenService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
@Service
public class AuthService {
// Spring Security infrastructure: checks username/password and returns Authentication.
private final AuthenticationManager authenticationManager;
// Our part: issue JWT after successful authentication.
private final TokenService tokenService;
public AuthService(AuthenticationManager authenticationManager, TokenService tokenService) {
this.authenticationManager = authenticationManager;
this.tokenService = tokenService;
}
public AuthResponse login(LoginRequest request) {
Authentication authResult = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(
request.getLogin(),
request.getPassword()
)
);
IssuedAccessToken token = tokenService.issueAccessToken(authResult);
return new AuthResponse(token.value(), "Bearer", token.expiresInSeconds());
}
}
What is new at this stage is only one thing: the call tokenService.issueAccessToken(...) now really knows where the claim set, secret, and TTL come from. Everything else is already the familiar login flow skeleton.
AuthController: the controller does not know about secret and TTL
Nothing exotic happens at the web boundary: the controller still accepts a LoginRequest, delegates to AuthService, and returns an AuthResponse. It does not need the secret, the TTL, or signing details — and that is a good sign that the layers are not mixed up.
Request/response example
Below is an example of a manual check through curl. We are not discussing where the client stores the token, and we are not discussing protected endpoints. We only want to see that the login endpoint returns the expected JSON.
# POST to the login endpoint, pass username/password in JSON
curl -i -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"login":"alice","password":"password"}'
Example response (truncated, token shortened):
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9....",
"tokenType": "Bearer",
"expiresIn": 900
}
If you see such a response and understand where these three fields come from, you already have a working stateless login flow. At this point, the token stops being an abstraction: it is really born in the login flow, knows its TTL, and carries a clear claim set. And then the next engineering question naturally appears: what do we do with this Authorization: Bearer ... on ordinary requests, how do we verify the token, and how do we build Authentication again for the SecurityContext.
5. Hygiene: secret and token
At this stage it is very easy to “erase” all the benefit of moving configuration out if you start logging the secret, returning it in errors, or keeping it in code “just in case.” So we need literally a couple of simple rules that protect your reputation better than any encryption. Secret is secret. Token is almost secret. If you are in doubt, behave as if future log reviewers are reading you right now (they do exist, by the way: it is you in a month).
The first practical rule is do not log the token in full. For debugging, the user and expiresIn are enough. If you really need to “see the token,” print only the first 10–15 characters and only in the local profile.
package com.example.securecontent.auth;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class AuthLog {
private static final Logger log = LoggerFactory.getLogger(AuthLog.class);
public static void issued(String username, long expiresInSec) {
// Do not log the token itself: in logs it lives long and may leak.
log.info("Issued access token for user={}, expiresInSec={}", username, expiresInSec);
}
}
The second rule is the secret must not be “just a string in Git”. Even if “it is only a learning project.” A learning project is exactly where habits get reinforced. Let the habit be a good one: the secret comes from an environment variable.
If you run the application locally, you can do this (on your machine):
# Set the secret as an environment variable on the developer machine
export APP_SECURITY_JWT_SECRET="local-dev-secret-please-change"
./gradlew bootRun
And yes, the secret should be longer than “123”. Otherwise you will be signing tokens with something that can be guessed faster than you can finish your tea.
6. Typical JWT mistakes
Mistake #1: hardcode the secret in code “to make it faster.”
This usually starts as an innocent "secret" in JwtTokenService, and ends with that secret living in the repository for years. Even if the project is educational, you are training a habit. The right place for the secret is external configuration (env var or secret storage), and in the code only the ${...} key remains.
Mistake #2: pull @Value with the secret into the controller.
Sometimes it seems logical: “the controller handles login, so let it know the secret.” That breaks layer boundaries. The controller should be dumb and safe: it should not have access to the secret at all. The fewer components know the secret, the lower the chance that someone will log it by accident, return it in an error, or drag it into debugging.
Mistake #3: mix up TTL units.
This is the case where the application “works,” but security or UX becomes weird. Tokens suddenly expire immediately or live too long. In this learning project we fix TTL in milliseconds, but it is more convenient to return expiresIn in the response in seconds. If you do the conversion, do it in exactly one place and name variables so the units are readable at a glance.
Mistake #4: duplicate TTL in multiple places.
For example, you wrote TTL in application.yml, and then “for convenience” you also wrote 15 * 60 * 1000 in TokenService. A week later you change TTL in the config, but token' s keep living according to the old time, and you will be searching for “why exp does not match.” TTL should live in configuration and reach the code through DI as a single source of truth.
Mistake #5: log the token in full (or worse — the Authorization header).
In logs, the token lives a long time, often ends up in log aggregation systems, and log access is usually broader than database access. So a token in logs is almost like a “public link to your account.” For diagnostics, logging the username, status, and lifetime is enough. If you really need to, log only a piece of the token and only in the local profile.
GO TO FULL VERSION