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.1. Security Flow Overview

jwt-flow-diagram

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:

  1. Validates userHash using secret from application.properties

  2. Exchanges credentials with Admin Service using gateway’s own API key

  3. Receives JWT token from Admin Service

  4. Stores JWT in HTTP session (not exposed to Angular)

  5. 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:

  1. User navigates from customer website

  2. Gateway creates HTTP session

  3. Stores userId/userHash in session

  4. 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:

  1. JWT Validation - Admin Service verifies token signature and expiration

  2. User Context - Extracts userId/personId from JWT claims

  3. Organization Resolution - Determines user’s organization memberships via OrgUser entity

  4. Person Resolution - Identifies linked persons via LinkedPerson entity

  5. 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();
    }
}