Backend Security — How Only Authorized Requests Are Accepted
Part of the
docs/security/set — see README.md for the topic index.
Every HTTP request to the MotorPH API passes through a layered security pipeline before any business logic runs. This document walks through each layer, explains the JWT implementation in detail, and calls out the specific best practices applied in the code.
The Four Layers of Defense
Incoming HTTP request
│
▼
┌─────────────────────────────────┐
│ Layer 1: CORS Policy │ Reject wrong origins before anything else
└──────────────┬──────────────────┘
│
▼
┌─────────────────────────────────┐
│ Layer 2: JwtAuthenticationFilter│ Validate token, populate security context
└──────────────┬──────────────────┘
│
▼
┌─────────────────────────────────┐
│ Layer 3: URL Access Rules │ Public vs. protected endpoint matching
└──────────────┬──────────────────┘
│
▼
┌─────────────────────────────────┐
│ Layer 4: @PreAuthorize │ Permission check on individual methods
└──────────────┬──────────────────┘
│
▼
Controller / Business Logic
Layer 1 — CORS Policy
File: SecurityConfiguration.java
configuration.setAllowedOrigins(corsAllowedOrigins); // env-driven, not "*"
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowCredentials(true);
The allowed origins list is injected from the environment variable CORS_ALLOWED_ORIGINS (default: http://localhost:5173). This means the browser will block preflight requests from any origin not on the list before a JWT even reaches the filter.
Layer 2 — JWT Authentication Filter
File: JwtAuthenticationFilter.java
This is where every non-public request is validated. It extends OncePerRequestFilter, which guarantees it runs exactly once per request — never twice, never skipped.
What it does, step by step
// 1. Read the Authorization header
String header = request.getHeader("Authorization");
// 2. If header is missing or doesn't start with "Bearer " — let it through
// (the URL access rule in Layer 3 will block it if authentication is required)
if (header == null || !header.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// 3. Strip the "Bearer " prefix to get the raw token string
String token = header.substring("Bearer ".length());
// 4. Validate token AND check that the security context is empty
if (jwtTokenManager.validateToken(token)
&& SecurityContextHolder.getContext().getAuthentication() == null) {
// 5. Extract username from the token's subject claim
String username = jwtTokenManager.getUsernameFromToken(token);
// 6. Re-load the user from the database (confirms user still exists)
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 7. Only proceed if the user account is Active
if (userDetails.isEnabled()) {
// 8. Build an authentication token with the user's authorities
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
// 9. Place the authenticated user into the security context
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
// 10. Continue — Layer 3 decides if an empty security context is acceptable
filterChain.doFilter(request, response);
Key point: the filter never short-circuits with a 401 directly. It either populates the security context or leaves it empty. The decision to reject the request is made downstream by Spring Security's access rules (Layer 3) or JwtAuthenticationEntryPoint.
Layer 3 — URL Access Rules
File: SecurityConfiguration.java
.authorizeHttpRequests(auth -> auth
.requestMatchers(PUBLIC_ENDPOINTS).permitAll()
.anyRequest().authenticated()
)
Public endpoints (no token required):
| Path | Purpose |
|---|---|
POST /api/auth/login | Login |
/api/public/** | Public data |
/swagger-ui/**, /swagger-ui.html | API docs |
/v3/api-docs/** | OpenAPI spec |
/ws/** | WebSocket |
Everything else falls under .anyRequest().authenticated(). If the security context is empty at this point (token was missing, invalid, or the user was disabled), Spring Security invokes JwtAuthenticationEntryPoint, which returns:
{
"message": "Unauthorized: authentication is required to access this resource",
"status": 401,
"time": "2026-06-24T10:00:00"
}
It never leaks a stack trace or HTML error page — always a clean JSON response.
Layer 4 — Method-Level Authorization (@PreAuthorize)
Enabled by: @EnableMethodSecurity on SecurityConfiguration.java
Even after a request is authenticated, each controller method can require a specific permission. Spring Security checks this against the GrantedAuthority set built from the user's permissions.
// Single permission required
@PreAuthorize("hasAuthority('" + PermissionConstants.HR_EMPLOYEES_CREATE + "')")
public ResponseEntity<?> createEmployee(...) { ... }
// Either permission grants access
@PreAuthorize("hasAnyAuthority('" + PermissionConstants.HR_OVERTIME_REQUESTS_APPROVE
+ "', '" + PermissionConstants.PAYROLL_OVERTIME_APPROVE + "')")
public ResponseEntity<?> approveOvertime(...) { ... }
If the user is authenticated but lacks the required authority → 403 Forbidden (not 401).
This means authentication (who you are) and authorization (what you can do) are enforced at two separate layers, independently.
The JWT in Detail
File: JwtTokenManager.java
Structure of a generated token
Header { "alg": "HS256", "typ": "JWT" }
Payload {
"sub": "jdoe", ← username (used to reload user from DB)
"iss": "motorph-payroll", ← issuer claim (validated on decode)
"userId": 1,
"employeeId": 100,
"roles": ["Employee", "HR Administrator"],
"iat": 1750000000, ← issued-at (Unix timestamp)
"exp": 1750000900 ← expires-at (iat + 15 min)
}
Signature HMAC256(base64(header) + "." + base64(payload), secretKey)
Generation
public String generateToken(AuthenticatedUser user) {
Instant now = Instant.now();
return JWT.create()
.withSubject(user.getUsername())
.withIssuer(jwtProperties.getIssuer())
.withClaim("userId", user.getUserId())
.withClaim("employeeId", user.getEmployeeId())
.withClaim("roles", new ArrayList<>(user.getRoles()))
.withIssuedAt(Date.from(now))
.withExpiresAt(Date.from(now.plus(jwtProperties.getAccessTokenExpirationMinute(), ChronoUnit.MINUTES)))
.sign(getAlgorithm()); // HMAC256(secretKey)
}
Access tokens are intentionally short-lived (15 min, not 24h) because they can't be individually revoked -- there's no server-side check on every request for a blacklisted jti, since that would mean a DB/cache hit on every single authenticated call. Instead, the paired refresh token (opaque, DB-backed, rotated on every use, revocable) is the thing that's actually checked against a live store, and it's what logout/reuse-detection act on. See "Login Protection & Token Revocation" below and authentication.md for the full refresh/logout/lockout flow -- this file focuses on the request-validation pipeline that existed before that work, plus a summary of what changed.
Validation
public boolean validateToken(String token) {
try {
decodeToken(token); // throws if anything is wrong
return true;
} catch (Exception e) {
return false; // expired, bad signature, wrong issuer, malformed
}
}
private DecodedJWT decodeToken(String token) {
return JWT.require(getAlgorithm())
.withIssuer(jwtProperties.getIssuer()) // issuer must match
.build()
.verify(token); // verifies signature + expiration atomically
}
JWT.require(...).build().verify() from com.auth0:java-jwt performs all checks in one call:
- HMAC256 signature integrity
issclaim equals"motorph-payroll"expclaim is in the future
Any failure throws an exception → validateToken returns false → security context stays empty → request is rejected.
Login Protection & Token Revocation
Two gaps in the original design: unlimited login attempts, and a 24h token with no way to invalidate it before expiry. Both are addressed without adding new infrastructure (no Redis, no blacklist table checked on every request):
LoginRateLimitFilter— Bucket4j token buckets (greedy refill, per client IP + path) ahead of/api/auth/login,/api/auth/refresh, the portal login/register endpoints and both halves of public signup; rejections are 429. Credential endpoints and/api/public/signup/verifyget 10/min;/api/public/signupgets 6/hour, because it sends mail to a caller-chosen address rather than checking a credential (../backend/self-serve-signup.md). Token buckets smooth bursts a fixed window would let straddle its boundary. In-memory is deliberate: this deployment runs one backend instance per client (../deployment/vps-guide.md), so there's no second instance for a shared counter to be inconsistent with -- and Bucket4j's JCache/Redis backends are the drop-in swap if that changes.- ALTCHA proof-of-work CAPTCHA —
AltchaVerificationFilter(enabled in production viaALTCHA_ENABLED) requires a solved, HMAC-signed, single-use challenge in theX-Altcha-Payloadheader on login, portal login/register, and/api/public/signup— but deliberately not/api/public/signup/verify, where the emailed code is itself the single-use credential (self-serve signup); anything missing/invalid/expired/replayed is 428 before authentication runs. Self-hosted (MITorg.altcha:altcha), zero external calls; replay protection via an in-memory registry of accepted signatures retained for the challenge lifetime. - Account lockout with exponential backoff —
LoginAttemptServicetracksusers.failed_login_attemptsand locks the account (locked_until) after 5 consecutive failures: 15 minutes at first, doubling per subsequent failure up to a 24h cap.AuthenticatedUser.isAccountNonLocked()reports this to Spring Security'sDaoAuthenticationProvider, which rejects a locked account withLockedExceptionbefore comparing the password. - Refresh tokens — short 15-minute access tokens are paired with an opaque, rotating refresh token (
RefreshTokenService,refresh_tokenstable). Every/api/auth/refreshcall revokes the presented token and issues a new one in the same family; presenting an already-rotated token a second time is treated as theft and revokes the whole family. This is what makes "logout" and "compromised token" mean something for a stateless-JWT system without needing a per-request revocation check.
Key files: LoginRateLimitFilter.java, RefreshTokenService.java, V75__auth_hardening.sql. Full request/response detail in authentication.md.
Security Headers
File: SecurityConfiguration.java
Beyond Spring Security's own defaults, the filter chain explicitly sets:
.headers(headers -> headers
.contentTypeOptions(Customizer.withDefaults())
.frameOptions(frame -> frame.deny())
.httpStrictTransportSecurity(hsts -> hsts.includeSubDomains(true).preload(true).maxAgeInSeconds(31536000))
.referrerPolicy(referrer -> referrer.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
.permissionsPolicyHeader(permissions -> permissions.policy("geolocation=(), camera=(), microphone=(), payment=()"))
.contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'; ...")))
HSTS is safe to always configure here even though this origin is plain HTTP in local dev: Spring Security's HstsHeaderWriter only actually emits the header when it detects the request came in over HTTPS (request.isSecure()), so it's a no-op until TLS terminates somewhere in front of this app.
The frontend nginx layer (frontend/nginx.conf, infra/nginx-client.conf.template, demo/nginx.demo.conf) sets the equivalent headers for the SPA itself, scoped to location / only -- not the /api/ proxy location, since that would duplicate every header the backend already sets on its own responses. Duplicate X-Frame-Options in particular isn't just redundant: some browsers treat "DENY, DENY" as an unrecognized value and silently ignore the header. No HSTS at the nginx/Traefik layer either, for the same reason it'd be premature there: Traefik (infra/traefik) only has an HTTP entrypoint today, so sending HSTS would make browsers cache a policy the origin can't back up.
Password Security
File: PasswordEncoderConfiguration.java
Map<String, PasswordEncoder> encoders = Map.of(
"argon2", new Argon2PasswordEncoder(16, 32, 1, 19456, 2), // OWASP: 19 MiB, t=2, p=1
"bcrypt", new BCryptPasswordEncoder());
DelegatingPasswordEncoder delegating = new DelegatingPasswordEncoder("argon2", encoders);
delegating.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder());
New hashes are Argon2id (memory-hard -- GPU/ASIC cracking rigs lose their advantage, unlike bcrypt) stored as {argon2}$argon2id$.... Legacy rows hold raw $2a$ bcrypt with no {id} prefix, which is what setDefaultPasswordEncoderForMatches handles; they keep matching and are transparently re-hashed to Argon2id on the next successful login:
- ERP users:
DaoAuthenticationProvider.setUserDetailsPasswordService(userDetailsService)-- after a successful password check, Spring Security consultsupgradeEncoding()and callsUserDetailsServiceImpl.updatePasswordwith the re-encoded password. - Portal users:
PortalAuthServiceImpl.login()performs the same upgrade explicitly, since portal auth never passes through the provider.
Migration progress is observable via the motorph_auth_password_rehash_total{store="erp"|"portal"} counter. Rollback caveat: once a row is {argon2}, reverting to the old plain-BCrypt encoder would break that user's login.
Permission Resolution at Login
File: UserDetailsServiceImpl.java
Permissions are never read from the JWT token — they are always re-loaded from the database on every authenticated request. This ensures that if a user's role or permissions change, the next request reflects the new state without needing to issue a new token.
// Walk the role hierarchy recursively to collect inherited permissions
private void collectPermissions(Role role, Set<String> accumulator) {
if (role == null) return;
role.getPermissions().forEach(p -> accumulator.add(p.getPermissionName()));
collectPermissions(role.getParentRole(), accumulator); // recurse up the tree
}
The resulting Set<GrantedAuthority> is what @PreAuthorize checks against — each authority is a fine-grained permission string like hr.employees.manage, not a role name.
Active account gate
@Override
public boolean isEnabled() {
return "Active".equalsIgnoreCase(status);
}
Even with a valid, non-expired token, a deactivated account (status != "Active") cannot authenticate. The filter checks userDetails.isEnabled() before setting the security context.
Best Practices Applied
1. Stateless sessions
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
The server never creates or stores an HTTP session. Every request is independently authenticated via its token. This makes the API horizontally scalable with no session affinity requirement.
2. CSRF disabled — justified
.csrf(csrf -> csrf.disable())
CSRF attacks exploit the browser's automatic cookie attachment to session-bearing requests. Because this API uses Authorization: Bearer headers (not cookies) and is stateless, CSRF cannot be exploited. Disabling it is the correct choice here, not a shortcut.
3. Secrets via environment variables, with a fail-fast check
jwt:
secretKey: ${JWT_SECRET:change-me-in-production}
motorph:
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173}
No secret is hardcoded. change-me-in-production used to just be a visually-obvious default in case of misconfiguration -- it didn't actually stop anything. JwtProperties now validates its own bound value in a @PostConstruct hook and refuses to start if secretKey is blank, still equals that placeholder, or is under 32 characters:
@PostConstruct
void validateSecret() {
if (INSECURE_DEFAULT_SECRET.equals(secretKey)) {
throw new IllegalStateException("JWT_SECRET is still set to the insecure placeholder default...");
}
// ...blank / too-short checks
}
This runs unconditionally (not gated on a "prod" Spring profile), so it can't be forgotten by a deployment that never sets spring.profiles.active correctly -- any environment, including local dev, must have a real JWT_SECRET in its .env. The three inventory microservices (MotorPH Event-Driven Inventory System/services/*/security/JwtProperties.java) apply the identical check via a record compact constructor, their equivalent of @PostConstruct validation.
4. Security context double-check
if (jwtTokenManager.validateToken(token)
&& SecurityContextHolder.getContext().getAuthentication() == null) {
The && ... == null guard prevents overwriting an already-authenticated security context if multiple filters run. It also avoids unnecessary DB queries on requests that are already authenticated upstream.
5. Permission-based authorities, not role names
GrantedAuthority objects hold permission strings (hr.employees.manage), not role names (HR_ADMINISTRATOR). This means @PreAuthorize decisions are made at the permission level — adding or removing a permission from a role immediately affects access without changing any code.
6. DB re-load on every request (not token claims)
Permissions are re-fetched from the database on every authenticated request, not read from the JWT payload. The token only carries roles[] for informational purposes. The authoritative source of truth for what a user can do is always the database.
7. @Transactional(readOnly = true) on UserDetailsService
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) { ... }
This signals to the JPA provider that the transaction will not write anything, allowing read optimizations (no dirty checking, potential read replicas) and preventing accidental writes during auth.
8. Consistent JSON error responses
JwtAuthenticationEntryPoint always returns a structured JSON body with a message, status, and time — never an HTML error page, never a raw Spring exception. This is important for the frontend's axios interceptor, which checks for 401 and triggers logout.
9. OncePerRequestFilter
JwtAuthenticationFilter extends OncePerRequestFilter, which Spring guarantees runs exactly once per request even in forward/include dispatch chains. This prevents double-authentication processing.
10. Utility class cannot be instantiated
public final class SecurityConstants {
private SecurityConstants() {
throw new UnsupportedOperationException("Constants class cannot be instantiated");
}
...
}
Constants are final and the private constructor throws to prevent accidental instantiation or inheritance — a small but explicit design guardrail.
11. Schema validation, not auto-creation
spring:
jpa:
hibernate:
ddl-auto: validate
Hibernate never auto-creates or modifies the schema. Flyway owns migrations. This prevents silent schema drift and ensures production deployments fail loudly if the schema doesn't match the entity model.
12. open-in-view: false
spring:
jpa:
open-in-view: false
Disables the Open Session in View anti-pattern, which keeps database connections open through the full HTTP response rendering phase. With it off, lazy-loading outside a @Transactional boundary fails fast rather than silently hitting the DB from a controller or view layer.
Summary Table
| Concern | Mechanism | Location |
|---|---|---|
| Token signing | HMAC256 with env-injected secret | JwtTokenManager |
| Token validation | Signature + issuer + expiry | JwtTokenManager.validateToken() |
| Access token lifetime | 15 min, not individually revocable | JwtProperties.accessTokenExpirationMinute |
| Refresh & revocation | Opaque, rotating, DB-backed, reuse-detected | RefreshTokenService, refresh_tokens table |
| Login throttling | 10 req/min per IP+path, 6/hour for signup (in-memory) | LoginRateLimitFilter |
| Account lockout | 5 failed attempts → 15 min lock | AuthController, users.locked_until |
| Request interception | OncePerRequestFilter | JwtAuthenticationFilter |
| Password storage | Argon2id (new hashes); legacy BCrypt re-hashed on login | PasswordEncoderConfiguration |
| Unauthenticated response | JSON 401 via entry point | JwtAuthenticationEntryPoint |
| Locked-account response | JSON 423 | GlobalControllerAdvice.handleLocked |
| Unauthorized response | JSON 403 via @PreAuthorize | Every controller method |
| Permission resolution | DB re-load + role inheritance walk | UserDetailsServiceImpl |
| Account deactivation | isEnabled() checked in filter | AuthenticatedUser.isEnabled() |
| Session management | Stateless (no HTTP session) | SecurityConfiguration |
| CORS | Env-driven origin allowlist | SecurityConfiguration |
| Security headers | CSP/HSTS/X-Frame-Options/etc. | SecurityConfiguration, nginx configs |
| Secret validation | Fails startup on placeholder/weak/blank | JwtProperties.validateSecret() |
| Audit trail | Success + failed logins + lockouts + denied requests | AuditService, AuditInterceptor |
| Schema safety | ddl-auto: validate + Flyway | application.yml |