Skip to main content

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):

PathPurpose
POST /api/auth/loginLogin
/api/public/**Public data
/swagger-ui/**, /swagger-ui.htmlAPI 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
  • iss claim equals "motorph-payroll"
  • exp claim 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/verify get 10/min; /api/public/signup gets 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 CAPTCHAAltchaVerificationFilter (enabled in production via ALTCHA_ENABLED) requires a solved, HMAC-signed, single-use challenge in the X-Altcha-Payload header 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 (MIT org.altcha:altcha), zero external calls; replay protection via an in-memory registry of accepted signatures retained for the challenge lifetime.
  • Account lockout with exponential backoffLoginAttemptService tracks users.failed_login_attempts and 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's DaoAuthenticationProvider, which rejects a locked account with LockedException before comparing the password.
  • Refresh tokens — short 15-minute access tokens are paired with an opaque, rotating refresh token (RefreshTokenService, refresh_tokens table). Every /api/auth/refresh call 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 consults upgradeEncoding() and calls UserDetailsServiceImpl.updatePassword with 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

ConcernMechanismLocation
Token signingHMAC256 with env-injected secretJwtTokenManager
Token validationSignature + issuer + expiryJwtTokenManager.validateToken()
Access token lifetime15 min, not individually revocableJwtProperties.accessTokenExpirationMinute
Refresh & revocationOpaque, rotating, DB-backed, reuse-detectedRefreshTokenService, refresh_tokens table
Login throttling10 req/min per IP+path, 6/hour for signup (in-memory)LoginRateLimitFilter
Account lockout5 failed attempts → 15 min lockAuthController, users.locked_until
Request interceptionOncePerRequestFilterJwtAuthenticationFilter
Password storageArgon2id (new hashes); legacy BCrypt re-hashed on loginPasswordEncoderConfiguration
Unauthenticated responseJSON 401 via entry pointJwtAuthenticationEntryPoint
Locked-account responseJSON 423GlobalControllerAdvice.handleLocked
Unauthorized responseJSON 403 via @PreAuthorizeEvery controller method
Permission resolutionDB re-load + role inheritance walkUserDetailsServiceImpl
Account deactivationisEnabled() checked in filterAuthenticatedUser.isEnabled()
Session managementStateless (no HTTP session)SecurityConfiguration
CORSEnv-driven origin allowlistSecurityConfiguration
Security headersCSP/HSTS/X-Frame-Options/etc.SecurityConfiguration, nginx configs
Secret validationFails startup on placeholder/weak/blankJwtProperties.validateSecret()
Audit trailSuccess + failed logins + lockouts + denied requestsAuditService, AuditInterceptor
Schema safetyddl-auto: validate + Flywayapplication.yml