JWT Authentication and Token Flow
1. Overview
The Event Management system uses a hash-based JWT authentication architecture where the Registration Portal (gateway) mediates between customer websites and the Admin Service. This authentication method is designed for websites without OAuth2/OIDC capability (e.g., WordPress sites).
|
For organizations with external Identity Providers (IdP) supporting OAuth2/OIDC, see OAuth2/OIDC Authentication as an alternative authentication method. |
Key Components:
-
Customer Website/Portal - External system providing userId and userHash (e.g., WordPress with custom plugin)
-
Registration Portal - JHipster gateway with Angular frontend
-
Admin Service - Backend API that issues and validates JWT tokens
Security Approach:
-
Server-Side Token Storage - JWT never exposed to Angular
-
Session-Based Authentication - Standard HTTP session management
-
Hash-Based Credentials - Customer website sends userId + hash instead of password
-
API Key Authentication - Gateway authenticates to Admin Service
-
Stateless Backend - Admin Service validates JWT without session state
1.1. Authentication Methods Comparison
| Method | Use Case | Documentation |
|---|---|---|
Hash-Based JWT (this page) |
Websites without OAuth2/OIDC capability |
WordPress sites with custom plugin, legacy CMS platforms |
OAuth2/OIDC |
Organizations with external IdP/SSO |
OAuth2/OIDC Authentication (work in progress) |
1.2. JWT Authentication Flow
The system uses a JWT-based security architecture with session-backed token storage, involving three primary components:
-
Customer Website/Portal - Holds userId and userHash credentials
-
Registration Portal - Angular frontend + Spring Boot gateway (JHipster)
-
Admin Service - JWT issuer and validator (backend)
1.2.2. Authentication Steps
Step 1: Initial Navigation with Credentials
The customer website directs users to the Registration Portal with credentials via standard browser navigation:
https://registration.example.com/membership/register/42?userId=123&userHash=5d41402abc4b2a76b9719d911017c592&h=...
Parameters:
-
userId- User identifier from customer system -
userHash- Hash of userId + secret for validation -
h- Additional hash for registration link security (userKey-based)
Step 2: Token Exchange
Frontend Request:
Angular calls the gateway’s external login endpoint:
POST /api/auth/external-login
Content-Type: application/json
{
"userId": "123",
"userHash": "5d41402abc4b2a76b9719d911017c592"
}
Gateway Processing:
-
Validates userHash using secret from application.properties
-
Exchanges credentials with Admin Service using gateway’s own API key
-
Receives JWT token from Admin Service
-
Stores JWT in HTTP session (not exposed to Angular)
-
Returns success response without token
@PostMapping("/api/auth/external-login")
public ResponseEntity<Void> externalLogin(@RequestBody ExternalLoginRequest request,
HttpSession session) {
// Validate hash server-side
String expectedHash = hashService.generateHash(request.getUserId());
if (!expectedHash.equals(request.getUserHash())) {
throw new UnauthorizedException("Invalid credentials");
}
// Exchange with Admin Service using API key
String jwt = adminServiceClient.exchangeCredentials(
request.getUserId(),
apiKeyCredentials
);
// Store in session (NOT sent to client)
session.setAttribute("JWT_TOKEN", jwt);
return ResponseEntity.ok().build();
}
Admin Service Token Exchange:
POST /api/auth/exchange
Authorization: ApiKey <gateway-api-key>
Content-Type: application/json
{
"userId": "123"
}
Response:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresIn": 3600
}
JWT Token Structure:
{
"sub": "user123",
"iss": "event-admin-service",
"aud": "registration-portal",
"exp": 1706789123,
"iat": 1706785523,
"userId": "123",
"scope": "public",
"authorities": ["ROLE_USER"]
}
Step 3: Session-Based JWT Storage
Key Security Principle: The JWT token is never exposed to the Angular frontend.
-
Gateway stores JWT in
HttpSession(server-side) -
Requires either:
-
Redis-backed Spring Session (for distributed deployments)
-
Sticky load balancing (for session affinity)
-
-
API responses contain no token information
Session Configuration:
spring:
session:
store-type: redis
redis:
namespace: registration-portal:session
timeout: 30m
Step 4: Gateway Filter JWT Injection
A custom filter intercepts requests to admin-service routes and injects the JWT:
@Component
public class JwtSessionFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) {
HttpSession session = request.getSession(false);
if (session != null) {
String jwt = (String) session.getAttribute("JWT_TOKEN");
if (jwt != null && isAdminServiceRoute(request)) {
// Inject JWT as Authorization header for proxied requests
request.setAttribute("PROXIED_JWT", jwt);
}
}
filterChain.doFilter(request, response);
}
private boolean isAdminServiceRoute(HttpServletRequest request) {
String path = request.getRequestURI();
return path.startsWith("/api/") || path.startsWith("/services/");
}
}
Spring Cloud Gateway Filter:
@Component
public class JwtGatewayFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return exchange.getSession()
.flatMap(session -> {
String jwt = session.getAttribute("JWT_TOKEN");
if (jwt != null) {
// Add Authorization header to proxied request
ServerHttpRequest modifiedRequest = exchange.getRequest()
.mutate()
.header("Authorization", "Bearer " + jwt)
.build();
return chain.filter(exchange.mutate()
.request(modifiedRequest)
.build());
}
return chain.filter(exchange);
});
}
@Override
public int getOrder() {
return -100; // High priority, before routing
}
}
Step 5: Admin Service JWT Validation
Admin Service validates the JWT bearer token for all authenticated requests:
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/exchange").hasAuthority("SCOPE_api-key")
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
);
return http.build();
}
}
1.2.3. Security Advantages
No Client-Side Token Exposure:
-
JWT never sent to Angular application
-
Prevents XSS attacks from stealing tokens
-
Reduces attack surface for token theft
Server-Side Validation:
-
Hash validation occurs server-side using secrets from application.properties
-
Client cannot bypass validation
-
Customer website secrets never exposed to browser
Proper Trust Boundaries:
-
Gateway acts as security boundary
-
Admin Service remains stateless (validates JWT only)
-
Clear separation of concerns
Session Management:
-
Standard HTTP session security
-
CSRF protection via session cookies
-
Familiar security patterns for web applications
1.2.4. Configuration Requirements
Gateway (Registration Portal):
# application.yml
security:
external-auth:
secret-key: ${EXTERNAL_AUTH_SECRET} # For userHash validation
admin-service:
api-key: ${ADMIN_SERVICE_API_KEY} # For token exchange
base-url: http://localhost:8080
spring:
session:
store-type: redis
timeout: 30m
Admin Service:
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080
jwk-set-uri: http://localhost:8080/.well-known/jwks.json
security:
api-keys:
registration-portal: ${REGISTRATION_PORTAL_API_KEY}
1.2.5. Session and Token Lifecycle
Session Creation:
-
User navigates from customer website
-
Gateway creates HTTP session
-
Stores userId/userHash in session
-
Exchanges for JWT and stores in session
Session Active:
-
All API requests include session cookie
-
Gateway retrieves JWT from session
-
JWT injected into proxied requests
-
Admin Service validates JWT
Session Expiry:
-
After 30 minutes of inactivity (configurable)
-
User must re-authenticate via customer website
-
JWT becomes invalid after expiration time
Token Refresh:
-
Gateway can refresh JWT before expiration
-
Transparent to Angular application
-
Maintains user session continuity
1.2.6. Error Scenarios
Invalid UserHash:
POST /api/auth/external-login
{
"userId": "123",
"userHash": "invalid_hash"
}
Response: 401 Unauthorized
{
"error": "Invalid credentials",
"message": "Hash validation failed"
}
Expired JWT:
GET /api/people
Session contains expired JWT
Gateway Response: 401 Unauthorized
{
"error": "Token expired",
"message": "Please re-authenticate"
}
Missing Session:
GET /api/people
No session or JWT in session
Gateway Response: 401 Unauthorized
{
"error": "Not authenticated",
"message": "Session not found or expired"
}
1.2.7. Deployment Considerations
Load Balancing:
-
Option 1: Redis-backed Spring Session (recommended)
-
Sessions shared across gateway instances
-
No sticky sessions required
-
Horizontal scaling support
-
-
Option 2: Sticky Sessions
-
Session affinity at load balancer
-
Simpler configuration
-
Limited scaling (session loss on instance failure)
-
High Availability:
-
Redis cluster for session store
-
Multiple gateway instances
-
JWT validation stateless at Admin Service
Security Hardening:
-
HTTPS only in production
-
Secure session cookies (
httpOnly,secure,sameSite) -
Short JWT expiration times (15-30 minutes)
-
Regular token refresh
-
API key rotation strategy
2. Integration with Multi-Dimensional Security
The JWT authentication system works in conjunction with the Multi-Dimensional Security System:
Authentication vs. Authorization:
-
Authentication (JWT) - Proves user identity
-
Authorization (Multi-Dimensional) - Controls data access based on organization and person relationships
Security Flow:
-
JWT Validation - Admin Service verifies token signature and expiration
-
User Context - Extracts userId/personId from JWT claims
-
Organization Resolution - Determines user’s organization memberships via OrgUser entity
-
Person Resolution - Identifies linked persons via LinkedPerson entity
-
Access Control - Applies organizational and personal security
Example Flow:
// 1. JWT Filter validates token
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, ...) {
String jwt = extractJwt(request);
if (jwtValidator.validate(jwt)) {
// Extract user context from JWT
Authentication auth = jwtConverter.convert(jwt);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
}
// 2. Security Service applies multi-dimensional rules
@Service
public class SecurityService {
public Specification<Event> hasOrgAccess(AccessLevel level) {
// Get current user from JWT authentication
String userId = getCurrentUserId();
// Query OrgUser to get organization memberships
List<Long> orgIds = orgUserRepository
.findByUserId(userId)
.stream()
.map(ou -> ou.getOrganisation().getId())
.collect(Collectors.toList());
// Build specification for org access
return (root, query, cb) ->
root.get("organisation").get("id").in(orgIds);
}
}
3. Gateway Implementation
3.1. External Login Endpoint
@RestController
@RequestMapping("/api/auth")
public class ExternalAuthController {
private final AdminServiceClient adminServiceClient;
private final HashValidationService hashService;
@PostMapping("/external-login")
public ResponseEntity<Void> externalLogin(
@RequestBody ExternalLoginRequest request,
HttpSession session) {
// Validate hash server-side
if (!hashService.validate(request.getUserId(), request.getUserHash())) {
throw new UnauthorizedException("Invalid credentials");
}
// Exchange credentials with Admin Service
TokenResponse tokenResponse = adminServiceClient.exchangeCredentials(
ExchangeRequest.builder()
.userId(request.getUserId())
.build()
);
// Store JWT in HTTP session (NOT sent to client)
session.setAttribute("JWT_TOKEN", tokenResponse.getToken());
session.setAttribute("JWT_EXPIRES_AT", tokenResponse.getExpiresAt());
return ResponseEntity.ok().build();
}
@PostMapping("/refresh")
public ResponseEntity<Void> refreshToken(HttpSession session) {
String currentJwt = (String) session.getAttribute("JWT_TOKEN");
if (currentJwt == null) {
throw new UnauthorizedException("No active session");
}
// Request new token from Admin Service
TokenResponse newToken = adminServiceClient.refreshToken(currentJwt);
// Update session with new token
session.setAttribute("JWT_TOKEN", newToken.getToken());
session.setAttribute("JWT_EXPIRES_AT", newToken.getExpiresAt());
return ResponseEntity.ok().build();
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpSession session) {
session.invalidate();
return ResponseEntity.ok().build();
}
}
3.2. Admin Service Client
@Service
public class AdminServiceClient {
private final WebClient webClient;
private final String apiKey;
public AdminServiceClient(WebClient.Builder builder,
@Value("${security.admin-service.api-key}") String apiKey,
@Value("${security.admin-service.base-url}") String baseUrl) {
this.webClient = builder.baseUrl(baseUrl).build();
this.apiKey = apiKey;
}
public TokenResponse exchangeCredentials(ExchangeRequest request) {
return webClient.post()
.uri("/api/auth/exchange")
.header("Authorization", "ApiKey " + apiKey)
.bodyValue(request)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError,
response -> Mono.error(new UnauthorizedException("Invalid credentials")))
.bodyToMono(TokenResponse.class)
.block();
}
public TokenResponse refreshToken(String currentToken) {
return webClient.post()
.uri("/api/auth/refresh")
.header("Authorization", "Bearer " + currentToken)
.retrieve()
.bodyToMono(TokenResponse.class)
.block();
}
}
3.3. Gateway Filter Configuration
@Configuration
public class GatewayFilterConfig {
@Bean
public GlobalFilter jwtInjectionFilter() {
return new JwtGatewayFilter();
}
}
@Component
@Order(-100) // High priority
public class JwtGatewayFilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getPath().value();
// Only inject JWT for admin-service routes
if (!path.startsWith("/api/") && !path.startsWith("/services/")) {
return chain.filter(exchange);
}
return exchange.getSession()
.flatMap(session -> {
String jwt = session.getAttribute("JWT_TOKEN");
if (jwt != null) {
ServerHttpRequest modifiedRequest = exchange.getRequest()
.mutate()
.header("Authorization", "Bearer " + jwt)
.build();
return chain.filter(
exchange.mutate().request(modifiedRequest).build()
);
}
return chain.filter(exchange);
});
}
}
4. Admin Service Implementation
4.1. Token Exchange Endpoint
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final JwtTokenProvider tokenProvider;
private final ApiKeyValidator apiKeyValidator;
private final UserService userService;
@PostMapping("/exchange")
public ResponseEntity<TokenResponse> exchangeCredentials(
@RequestHeader("Authorization") String apiKeyHeader,
@RequestBody ExchangeRequest request) {
// Validate API key from gateway
if (!apiKeyValidator.validate(apiKeyHeader)) {
throw new UnauthorizedException("Invalid API key");
}
// Verify user exists
User user = userService.findByUserId(request.getUserId())
.orElseThrow(() -> new NotFoundException("User not found"));
// Generate JWT with user claims
String jwt = tokenProvider.createToken(
JwtClaims.builder()
.subject(user.getId().toString())
.userId(user.getUserId())
.scope("public")
.authorities(user.getAuthorities())
.build()
);
return ResponseEntity.ok(
TokenResponse.builder()
.token(jwt)
.expiresIn(tokenProvider.getTokenValidity())
.expiresAt(Instant.now().plusSeconds(tokenProvider.getTokenValidity()))
.build()
);
}
@PostMapping("/refresh")
public ResponseEntity<TokenResponse> refreshToken(
@AuthenticationPrincipal Jwt jwt) {
// Extract user from current JWT
String userId = jwt.getClaimAsString("userId");
User user = userService.findByUserId(userId)
.orElseThrow(() -> new NotFoundException("User not found"));
// Issue new JWT
String newJwt = tokenProvider.createToken(
JwtClaims.builder()
.subject(user.getId().toString())
.userId(user.getUserId())
.scope("public")
.authorities(user.getAuthorities())
.build()
);
return ResponseEntity.ok(
TokenResponse.builder()
.token(newJwt)
.expiresIn(tokenProvider.getTokenValidity())
.expiresAt(Instant.now().plusSeconds(tokenProvider.getTokenValidity()))
.build()
);
}
}
4.2. JWT Token Provider
@Service
public class JwtTokenProvider {
private final Key signingKey;
private final long tokenValidityInSeconds;
public JwtTokenProvider(
@Value("${security.jwt.secret}") String secret,
@Value("${security.jwt.token-validity-seconds:1800}") long validity) {
this.signingKey = Keys.hmacShaKeyFor(secret.getBytes());
this.tokenValidityInSeconds = validity;
}
public String createToken(JwtClaims claims) {
Instant now = Instant.now();
Instant expiration = now.plusSeconds(tokenValidityInSeconds);
return Jwts.builder()
.setSubject(claims.getSubject())
.claim("userId", claims.getUserId())
.claim("scope", claims.getScope())
.claim("authorities", claims.getAuthorities())
.setIssuer("event-admin-service")
.setAudience("registration-portal")
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(expiration))
.signWith(signingKey, SignatureAlgorithm.HS512)
.compact();
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(signingKey)
.build()
.parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
public long getTokenValidity() {
return tokenValidityInSeconds;
}
}
4.3. Security Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // Stateless API
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.authorizeHttpRequests(authz -> authz
// Token exchange requires API key
.requestMatchers("/api/auth/exchange").hasAuthority("SCOPE_api-key")
// All other API requests require JWT
.requestMatchers("/api/**").authenticated()
// Public endpoints
.requestMatchers("/actuator/health").permitAll()
.anyRequest().permitAll()
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthorityPrefix("");
JwtAuthenticationConverter jwtAuthenticationConverter =
new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(
grantedAuthoritiesConverter
);
return jwtAuthenticationConverter;
}
@Bean
public JwtDecoder jwtDecoder(@Value("${security.jwt.secret}") String secret) {
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());
return NimbusJwtDecoder.withSecretKey(key).build();
}
}