Overview
SGIVU uses JSON Web Tokens (JWT) as the access token format for all API authentication. The sgivu-auth service issues JWTs signed with RSA-256, and all microservices validate them using the auth server’s public key.
JWTs are stateless - microservices can validate tokens without calling the auth server on every request, improving performance and reducing coupling.
JWT Structure
The JWT header specifies the algorithm and key ID:
{
"alg": "RS256",
"typ": "JWT",
"kid": "sgivu-jwt-key"
}
- alg: RSA-SHA256 signature algorithm
- typ: Token type (always “JWT”)
- kid: Key ID for rotation support (matches keystore alias)
Access Token Payload
The access token contains custom claims for authorization:
{
"sub": "12345",
"username": "john.doe",
"rolesAndPermissions": [
"ROLE_ADMIN",
"ROLE_USER",
"user:read",
"user:write",
"vehicle:read",
"vehicle:create"
],
"isAdmin": true,
"iss": "http://localhost:9000",
"aud": "sgivu-gateway",
"exp": 1709654400,
"iat": 1709652600,
"jti": "a1b2c3d4-e5f6-7890"
}
Standard Claims
| Claim | Description | Example |
|---|
sub | Subject (User ID) | "12345" |
iss | Issuer (Auth Server URL) | "http://localhost:9000" |
aud | Audience (Client ID) | "sgivu-gateway" |
exp | Expiration Time (Unix timestamp) | 1709654400 |
iat | Issued At (Unix timestamp) | 1709652600 |
jti | JWT ID (unique identifier) | "a1b2c3d4..." |
Custom Claims
| Claim | Type | Description |
|---|
username | String | Login username (for display) |
rolesAndPermissions | Array[String] | Combined roles and permissions |
isAdmin | Boolean | Quick admin check (true if has ROLE_ADMIN) |
Why is sub the User ID, not username?The sub claim should be immutable. Usernames can change, but user IDs remain constant throughout the user’s lifecycle. This prevents token invalidation when usernames are updated.
The rolesAndPermissions claim combines:
- Roles: Prefixed with
ROLE_ (e.g., ROLE_ADMIN, ROLE_USER)
- Permissions: Resource:action format (e.g.,
user:read, vehicle:create)
JWT Customizer Logic:
@Bean
OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer(UserDetailsService userDetailsService) {
return context -> {
Authentication principal = context.getPrincipal();
String username = principal.getName();
CustomUserDetails userDetails =
(CustomUserDetails) userDetailsService.loadUserByUsername(username);
Set<String> rolesAndPermissions = principal.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.map(authority -> {
// Add ROLE_ prefix if not already present
if (authority.matches("^[A-Z_]+$")) {
return "ROLE_" + authority;
}
return authority;
})
.collect(Collectors.toSet());
if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
context.getClaims()
.claim("sub", userDetails.getId())
.claim("username", username)
.claim("rolesAndPermissions", rolesAndPermissions)
.claim("isAdmin", rolesAndPermissions.contains("ROLE_ADMIN"));
}
};
}
ID Token Payload
The OIDC ID token has a different structure (used for user info, not API access):
{
"sub": "12345",
"userId": 12345,
"iss": "http://localhost:9000",
"aud": "sgivu-gateway",
"exp": 1712246400, // 30 days
"iat": 1709652600
}
The ID token has a 30-day TTL (matching refresh token lifetime) because it’s used as id_token_hint during OIDC logout. Spring Security never refreshes the ID token, so it must remain valid for the entire session.
Token Signing
RSA Key Pair
The auth server signs JWTs with an RSA private key stored in a JKS keystore:
@Bean
JWKSource<SecurityContext> jwkSource() {
Resource resource = resourceLoader.getResource(jwtProperties.keyStore().location());
KeyStore keyStore = KeyStore.getInstance("JKS");
keyStore.load(resource.getInputStream(), jwtProperties.keyStore().password().toCharArray());
RSAPrivateKey privateKey = (RSAPrivateKey) keyStore.getKey(
jwtProperties.key().alias(),
jwtProperties.key().password().toCharArray());
Certificate certificate = keyStore.getCertificate(jwtProperties.key().alias());
RSAPublicKey publicKey = (RSAPublicKey) certificate.getPublicKey();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(jwtProperties.key().alias())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}
Keystore Configuration
application.yml:
sgivu:
jwt:
keystore:
location: classpath:keystore.jks
password: ${KEYSTORE_PASSWORD}
key:
alias: sgivu-jwt-key
password: ${KEY_PASSWORD}
Security Best Practices:
- Never commit
keystore.jks to Git - add to .gitignore
- Use environment variables for passwords in production
- Load from secret manager (AWS Secrets Manager, HashiCorp Vault)
- Rotate keys periodically using the
kid header for versioning
Public Key Distribution (JWKS)
The auth server exposes the public key via the JWKS endpoint:
Request:
curl http://localhost:9000/oauth2/jwks
Response:
{
"keys": [
{
"kty": "RSA",
"e": "AQAB",
"kid": "sgivu-jwt-key",
"n": "xGOr-H7A..."
}
]
}
Microservices fetch this public key to validate JWT signatures.
Token Validation
Microservice Configuration
All microservices validate JWTs as OAuth2 Resource Servers:
@Bean
JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withIssuerLocation(
servicesProperties.getMap().get("sgivu-auth").getUrl())
.build();
}
This decoder:
- Fetches OIDC metadata from
/.well-known/openid-configuration
- Retrieves JWKS from
/oauth2/jwks
- Caches public keys locally
- Validates signature using RSA-256
- Checks standard claims:
iss, aud, exp, nbf
Validation Steps
For each incoming request with Authorization: Bearer <token>:
Spring Security Integration
Resource Server Configuration:
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(convert()))
)
.authorizeHttpRequests(authz -> authz
.requestMatchers("/v1/cars/**").access(internalOrAuthenticatedAuthorizationManager())
.anyRequest().authenticated()
);
return http.build();
}
JWT to GrantedAuthority Conversion:
@Bean
JwtAuthenticationConverter convert() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
List<String> rolesAndPermissions = jwt.getClaimAsStringList("rolesAndPermissions");
if (rolesAndPermissions == null || rolesAndPermissions.isEmpty()) {
return List.of();
}
return rolesAndPermissions.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
});
return converter;
}
This extracts rolesAndPermissions and converts them to Spring Security’s GrantedAuthority for use with @PreAuthorize.
Token Lifecycle
Token Timeouts
| Token Type | TTL | Rotation |
|---|
| Access Token | 30 minutes | No (expires and is replaced) |
| Refresh Token | 30 days | Yes (new token issued on refresh) |
| ID Token | 30 days | No (never refreshed) |
Configured in ClientRegistrationRunner:
private TokenSettings tokenSettings() {
return TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(30))
.refreshTokenTimeToLive(Duration.ofDays(30))
.reuseRefreshTokens(false) // Rotation enabled
.build();
}
Token Refresh
The gateway automatically refreshes expired access tokens using the refresh token:
@Bean
ReactiveOAuth2AuthorizedClientManager authorizedClientManager(...) {
RefreshTokenReactiveOAuth2AuthorizedClientProvider refreshTokenProvider =
new RefreshTokenReactiveOAuth2AuthorizedClientProvider();
refreshTokenProvider.setClockSkew(Duration.ofSeconds(5));
DelegatingReactiveOAuth2AuthorizedClientProvider authorizedClientProvider =
new DelegatingReactiveOAuth2AuthorizedClientProvider(
authorizationCodeProvider,
refreshTokenProvider
);
DefaultReactiveOAuth2AuthorizedClientManager manager =
new DefaultReactiveOAuth2AuthorizedClientManager(
clientRegistrationRepository,
authorizedClientRepository
);
manager.setAuthorizedClientProvider(authorizedClientProvider);
return manager;
}
Refresh Flow:
Refresh Token Rotation
With reuseRefreshTokens: false, each refresh issues a new refresh token and invalidates the old one:
Benefits:
- Limits token lifetime: Even if stolen, old refresh tokens become invalid
- Detects token theft: Reuse of old refresh token triggers revocation
- Reduces attack window: Attacker must use token before legitimate refresh
Invalid Grant Handling
When the refresh token is rejected (invalid_grant error):
return authorizedClientManager
.authorize(authorizeRequest)
.onErrorResume(ClientAuthorizationException.class, ex -> {
if (OAuth2ErrorCodes.INVALID_GRANT.equals(ex.getError().getErrorCode())) {
log.warn("Refresh token invalid, re-authentication required");
return Mono.empty(); // Results in 401
}
return Mono.error(ex);
});
Common Causes:
- Refresh token expired (30 days)
- Token revoked (user logged out)
- Auth server restarted (authorizations table cleared in dev)
- Token rotation detected reuse (security incident)
Result:
/auth/session returns 401
- Angular redirects to login
- User re-authenticates
Microservices extract JWTs from the Authorization header:
GET /v1/vehicles HTTP/1.1
Host: sgivu-vehicle:8082
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsInVzZXJuYW1lIjoiam9obi5kb2UiLCJyb2xlc0FuZFBlcm1pc3Npb25zIjpbIlJPTEVfQURNSU4iXSwiaXNBZG1pbiI6dHJ1ZSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo5MDAwIiwiYXVkIjoic2dpdnUtZ2F0ZXdheSIsImV4cCI6MTcwOTY1NDQwMCwiaWF0IjoxNzA5NjUyNjAwfQ.signature
Spring Security’s BearerTokenAuthenticationFilter automatically:
- Extracts token from
Bearer scheme
- Passes to
JwtDecoder for validation
- Creates
JwtAuthenticationToken with claims
- Stores in
SecurityContext
Accessing Claims in Code
In Controllers:
@GetMapping("/profile")
public ResponseEntity<UserProfile> getProfile(JwtAuthenticationToken authentication) {
Jwt jwt = authentication.getToken();
Long userId = jwt.getClaim("sub");
String username = jwt.getClaim("username");
Boolean isAdmin = jwt.getClaim("isAdmin");
List<String> rolesAndPermissions = jwt.getClaim("rolesAndPermissions");
return ResponseEntity.ok(new UserProfile(userId, username, isAdmin));
}
Using SecurityContext:
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth instanceof JwtAuthenticationToken jwtAuth) {
Long userId = jwtAuth.getToken().getClaim("sub");
}
Authorization with JWT Claims
Method-Level Security
Use @PreAuthorize with Spring Expression Language (SpEL):
@PreAuthorize("hasAuthority('vehicle:read')")
@GetMapping("/v1/vehicles/{id}")
public VehicleResponse getVehicle(@PathVariable Long id) {
return vehicleService.findById(id);
}
@PreAuthorize("hasAuthority('vehicle:create') and hasRole('ADMIN')")
@PostMapping("/v1/vehicles")
public VehicleResponse createVehicle(@RequestBody VehicleRequest request) {
return vehicleService.create(request);
}
Enable Method Security:
@Configuration
@EnableMethodSecurity
public class SecurityConfig { ... }
Custom Authorization Expressions
Check claims directly:
@PreAuthorize("authentication.token.claims['isAdmin'] == true")
@DeleteMapping("/v1/users/{id}")
public void deleteUser(@PathVariable Long id) {
userService.delete(id);
}
Route-Level Security
Configure in security filter chain:
http.authorizeHttpRequests(authz -> authz
.requestMatchers("/v1/vehicles/**").hasAnyAuthority("vehicle:read", "ROLE_ADMIN")
.requestMatchers("/v1/users/**").hasAuthority("user:read")
.anyRequest().authenticated()
)
Gateway Token Relay
The gateway adds tokens to proxied requests using the tokenRelay() filter:
Route Configuration:
.route("vehicle-service", r -> r
.path("/v1/vehicles/**")
.filters(f -> f.tokenRelay())
.uri("lb://sgivu-vehicle")
)
How it works:
- Extract
OAuth2AuthorizedClient from Redis session
- Get
accessToken from authorized client
- Check if expired → refresh if needed
- Add
Authorization: Bearer <token> header
- Forward to microservice
Token Revocation
The gateway revokes tokens during logout:
public class TokenRevocationServerLogoutHandler implements ServerLogoutHandler {
@Override
public Mono<Void> logout(WebFilterExchange exchange, Authentication authentication) {
return exchange.getExchange().getSession()
.flatMap(session -> {
// Get authorized client from session
return authorizedClientRepository.loadAuthorizedClient(
registrationId, authentication, exchange.getExchange()
);
})
.flatMap(authorizedClient -> {
// Revoke access token
revokeToken(authorizedClient.getAccessToken());
// Revoke refresh token
revokeToken(authorizedClient.getRefreshToken());
return Mono.empty();
});
}
}
Revocation Endpoint:
POST /oauth2/revoke HTTP/1.1
Host: sgivu-auth:9000
Content-Type: application/x-www-form-urlencoded
Authorization: Basic c2dpdnUtZ2F0ZXdheTpzZWNyZXQ=
token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Production Considerations
Token Size
JWTs are included in every request. Minimize size by:
- Avoid embedding large objects in claims
- Use claim names efficiently (short but readable)
- Consider token compression (not standard)
Current SGIVU Token Size: ~500-800 bytes (acceptable)
Clock Skew
Allow 5-second clock skew for expiration checks:
refreshTokenProvider.setClockSkew(Duration.ofSeconds(5));
Prevents tokens from being rejected due to minor time differences between servers.
Issuer URL Consistency
The iss claim in JWTs must exactly match the issuer URL configured in microservices. Mismatches cause validation failures.Auth Server:sgivu:
issuer:
url: https://api.example.com
Microservices:NimbusJwtDecoder.withIssuerLocation("https://api.example.com").build()
Both must use the same protocol (HTTP vs HTTPS) and hostname.
Key Rotation
Recommended Process:
- Generate new RSA key pair with new
kid (e.g., sgivu-jwt-key-2024)
- Add to keystore alongside old key
- Update
sgivu.jwt.key.alias to new kid
- Auth server starts signing with new key
- Old key remains in JWKS for 30 days (max refresh token lifetime)
- Remove old key after 30 days
Why 30 days? Tokens signed with the old key remain valid until they expire. Microservices need the old public key to validate them.