Authentication
Secure authentication patterns for verifying user and service identity in banking applications.
Overview
Authentication is the process of verifying the identity of a user or service. In banking applications, strong authentication is critical to prevent unauthorized access to financial data and transactions.
See also: Authorization for access control after authentication, and Security Overview for general security principles.
OAuth 2.0 and OpenID Connect
Use OAuth 2.0 for API authentication and OpenID Connect (OIDC) for user identity.
OAuth 2.0 Flows
Authorization Code flow is for web applications with secure backend servers that can store client secrets. Client Credentials is for service-to-service communication with no user involvement. PKCE (Proof Key for Code Exchange) is for mobile and single-page applications that cannot securely store secrets - PKCE prevents authorization code interception even without a client secret.
Authorization Code Flow (Web Applications)
The Authorization Code flow never exposes tokens to the browser. First, obtain an authorization code through a browser redirect. Then exchange that code for tokens in a secure back-channel request. This two-step process is crucial: the authorization code is useless without the client secret, which only the backend server knows. The browser redirect ensures user presence during authentication, preventing background attacks.
The sequence diagram illustrates front-channel (browser redirects) and back-channel (server-to-server) separation. The authorization code is exposed in the browser URL but useless without the client secret. Only the secure back-channel exchange yields actual access tokens.
Spring Security OAuth 2.0 Configuration
// Spring Security OAuth 2.0 Configuration
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
.failureUrl("/login?error=true")
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService())
)
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.authorizeHttpRequests(authz -> authz
.requestMatchers("/public/**", "/login", "/error").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/payments/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
);
return http.build();
}
private JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jwtAuthenticationConverter =
new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(
grantedAuthoritiesConverter
);
return jwtAuthenticationConverter();
}
@Bean
public OAuth2UserService<OAuth2UserRequest, OAuth2User> customOAuth2UserService() {
return new CustomOAuth2UserService();
}
}
This configuration enables both OAuth 2.0 login (browser-based) and JWT resource server functionality (API authentication). The oauth2Login section handles user login via OAuth providers. The oauth2ResourceServer section validates JWT tokens in API requests. The jwtAuthenticationConverter extracts roles from the JWT's "roles" claim and prefixes them with "ROLE_" to match Spring Security conventions. Session management limits users to one concurrent session.
Client Credentials Flow (Service-to-Service)
The Client Credentials flow authenticates services, not users. Service A presents its client ID and secret to the authorization server, receiving an access token. This flow never issues refresh tokens - services request new access tokens as needed. Use only for trusted backend services in controlled environments, never for client-side applications. Token caching prevents overwhelming the authorization server - cache tokens and request new ones 60 seconds before expiration.
Implementation
@Service
public class ServiceToServiceAuthClient {
@Value("${oauth.token-url}")
private String tokenUrl;
@Value("${oauth.client-id}")
private String clientId;
@Value("${oauth.client-secret}")
private String clientSecret;
private final RestTemplate restTemplate;
private String cachedToken;
private Instant tokenExpiry;
public String getAccessToken() {
// Check if cached token is still valid
if (cachedToken != null &&
tokenExpiry != null &&
Instant.now().isBefore(tokenExpiry.minusSeconds(60))) {
return cachedToken;
}
// Request new token
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "client_credentials");
body.add("client_id", clientId);
body.add("client_secret", clientSecret);
body.add("scope", "read:payments write:payments");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
HttpEntity<MultiValueMap<String, String>> request =
new HttpEntity<>(body, headers);
TokenResponse response = restTemplate.postForObject(
tokenUrl,
request,
TokenResponse.class
);
cachedToken = response.getAccessToken();
tokenExpiry = Instant.now().plusSeconds(response.getExpiresIn());
return cachedToken;
}
}
Token caching prevents redundant authorization server requests. Cached tokens are reused until 60 seconds before expiration. This buffer prevents tokens expiring mid-request. The grant_type=client_credentials parameter specifies service authentication. The scope parameter requests specific permissions - the server may grant fewer based on client configuration.
PKCE Flow (Mobile and Single-Page Applications)
PKCE (Proof Key for Code Exchange) prevents authorization code interception:
JSON Web Tokens (JWT)
JWT Structure
A JWT consists of three Base64-encoded parts separated by dots. Understanding this structure is critical for proper JWT usage and security.
header.payload.signature
eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyMTIzIiwibmFtZSI6IkpvaG4gRG9lIiwicm9sZXMiOlsiVVNFUiJdLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTUxNjIzOTkyMn0.
7HKqVvQ8J9vZ8YFSvZqK5wX0X9Q1Y0c9F8yJ4zK2wZqVZ9X8Y7K6wZ
The diagram below breaks down JWT's three-part structure. Each component serves a specific security purpose: the header declares how to verify the token, the payload contains identity and authorization claims, and the signature ensures the entire token hasn't been tampered with. Note that the header and payload are merely base64-encoded (easily decoded), not encrypted - only the signature provides integrity protection.
The JWT structure provides data transport and integrity verification. The header specifies the signing algorithm, preventing algorithm substitution attacks. The payload carries claims (user data, roles, expiration) but is only base64-encoded, not encrypted - anyone intercepting the token can read these claims. Never include sensitive data like passwords or credit card numbers. The signature, created using a secret key and the algorithm from the header, detects any modification to the header or payload - without the secret key, attackers cannot create valid signatures for tampered data.
JWT Best Practices
// GOOD: Proper JWT configuration
@Configuration
public class JwtConfig {
@Value("${jwt.secret}")
private String secret; // Load from environment/secrets manager
@Value("${jwt.expiration:900}") // 15 minutes default
private long expiration;
@Value("${jwt.refresh-expiration:604800}") // 7 days
private long refreshExpiration;
@Value("${jwt.issuer:payment-api}")
private String issuer;
public String generateAccessToken(User user) {
Instant now = Instant.now();
return Jwts.builder()
.setSubject(user.getId().toString())
.setIssuer(issuer)
.setAudience("payment-api")
.claim("email", user.getEmail())
.claim("roles", user.getRoles().stream()
.map(Role::getName)
.collect(Collectors.toList()))
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(now.plusSeconds(expiration)))
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public String generateRefreshToken(User user) {
Instant now = Instant.now();
return Jwts.builder()
.setSubject(user.getId().toString())
.setIssuer(issuer)
.claim("type", "refresh")
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(now.plusSeconds(refreshExpiration)))
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public Claims validateToken(String token) {
try {
return Jwts.parser()
.setSigningKey(secret)
.requireIssuer(issuer)
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) {
log.warn("JWT token expired: {}", e.getMessage());
throw new AuthenticationException("Token expired");
} catch (Exception e) {
log.error("JWT validation failed: {}", e.getMessage());
throw new AuthenticationException("Invalid token");
}
}
public UUID getUserIdFromToken(String token) {
Claims claims = validateToken(token);
return UUID.fromString(claims.getSubject());
}
}
// BAD: Insecure JWT configuration
String token = Jwts.builder()
.setSubject(username)
.signWith(SignatureAlgorithm.HS256, "secret123") // Hardcoded secret!
.setExpiration(new Date(System.currentTimeMillis() + 86400000 * 365)) // 1 year!
.compact();
JWT Security Rules
| Rule | Do | Don't |
|---|---|---|
| Algorithm | Use HS512 or RS256 | Use HS256 or none |
| Expiration | 15 min access, 7 days refresh | Long-lived tokens (days/months) |
| Secret Storage | Environment variables, secrets manager | Hardcode in source |
| Claims Validation | Validate exp, iss, aud | Trust payload without validation |
| Transport | HTTPS only | HTTP |
| Sensitive Data | Never store PII/secrets in JWT | Store card numbers, passwords |
| Token Storage | HttpOnly cookies (web), secure storage (mobile) | LocalStorage (XSS risk) |
Token Refresh Flow
Multi-Factor Authentication (MFA)
For sensitive operations, require additional verification beyond passwords.
Multi-factor authentication adds a second verification factor beyond passwords, protecting against credential theft. MFA should be required for high-value transactions and sensitive operations (see Authorization guidelines).
MFA Flow
MFA Implementation
@Service
public class MfaService {
@Autowired
private TotpService totpService;
@Autowired
private SmsService smsService;
@Autowired
private UserRepository userRepository;
public boolean isMfaRequired(User user, OperationType operation) {
// Always require MFA for admin operations
if (user.hasRole(UserRole.ADMIN)) {
return true;
}
// Require MFA for high-value payments
if (operation instanceof PaymentOperation) {
PaymentOperation payment = (PaymentOperation) operation;
return payment.getAmount().compareTo(new BigDecimal("10000")) > 0;
}
// Require MFA for sensitive account changes
if (operation instanceof AccountOperation) {
return operation.isSensitive();
}
return false;
}
public void sendMfaChallenge(User user, MfaMethod method) {
String code = generateMfaCode();
// Store code with expiration (5 minutes)
MfaChallenge challenge = new MfaChallenge();
challenge.setUserId(user.getId());
challenge.setCode(hashCode(code));
challenge.setMethod(method);
challenge.setExpiresAt(LocalDateTime.now().plusMinutes(5));
challenge.setAttempts(0);
mfaChallengeRepository.save(challenge);
// Send based on method
switch (method) {
case SMS:
smsService.sendCode(user.getPhoneNumber(), code);
break;
case EMAIL:
emailService.sendMfaCode(user.getEmail(), code);
break;
case TOTP:
// No sending needed - user uses authenticator app
break;
}
}
public boolean validateMfaCode(UUID userId, String code) {
MfaChallenge challenge = mfaChallengeRepository
.findLatestByUserId(userId)
.orElseThrow(() -> new MfaException("No MFA challenge found"));
// Check expiration
if (LocalDateTime.now().isAfter(challenge.getExpiresAt())) {
throw new MfaException("MFA code expired");
}
// Check attempts
if (challenge.getAttempts() >= 3) {
userService.lockAccount(userId, "Too many MFA attempts");
throw new MfaException("Too many attempts");
}
// Validate code
boolean valid = false;
if (challenge.getMethod() == MfaMethod.TOTP) {
valid = totpService.validateCode(userId, code);
} else {
valid = hashCode(code).equals(challenge.getCode());
}
challenge.setAttempts(challenge.getAttempts() + 1);
mfaChallengeRepository.save(challenge);
if (valid) {
challenge.setCompleted(true);
mfaChallengeRepository.save(challenge);
}
return valid;
}
private String generateMfaCode() {
return String.format("%06d", new SecureRandom().nextInt(1000000));
}
private String hashCode(String code) {
// Use bcrypt for code hashing
return BCrypt.hashpw(code, BCrypt.gensalt());
}
}
TOTP (Time-based One-Time Password)
TOTP generates time-sensitive codes based on a shared secret and the current time. The algorithm combines a secret key (known to server and authenticator app) with the current time divided into 30-second windows. Both parties independently compute the same 6-digit code. This works without network communication. The server validates codes by checking the current window and one window on either side (90 seconds total) for clock skew. TOTP is more secure than SMS - it requires physical device possession, and attackers cannot intercept codes through SIM swapping or network attacks.
// Google Authenticator compatible TOTP
@Service
public class TotpService {
private static final int TOTP_PERIOD = 30; // seconds
private static final int TOTP_DIGITS = 6;
public String generateSecret() {
byte[] secret = new byte[20];
new SecureRandom().nextBytes(secret);
return Base32.encode(secret);
}
public String getQrCodeUrl(User user, String secret) {
String issuer = "BankingApp";
String account = user.getEmail();
return String.format(
"otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=%d&period=%d",
URLEncoder.encode(issuer, StandardCharsets.UTF_8),
URLEncoder.encode(account, StandardCharsets.UTF_8),
secret,
URLEncoder.encode(issuer, StandardCharsets.UTF_8),
TOTP_DIGITS,
TOTP_PERIOD
);
}
public boolean validateCode(UUID userId, String code) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
String secret = user.getMfaSecret();
if (secret == null) {
throw new MfaException("TOTP not enabled for user");
}
long currentTime = System.currentTimeMillis() / 1000;
long window = currentTime / TOTP_PERIOD;
// Check current window and +/- 1 window (to account for clock skew)
for (int i = -1; i <= 1; i++) {
String expectedCode = generateTotpCode(secret, window + i);
if (code.equals(expectedCode)) {
return true;
}
}
return false;
}
private String generateTotpCode(String secret, long window) {
byte[] secretBytes = Base32.decode(secret);
byte[] data = ByteBuffer.allocate(8).putLong(window).array();
try {
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(secretBytes, "HmacSHA1"));
byte[] hash = mac.doFinal(data);
int offset = hash[hash.length - 1] & 0x0F;
int binary = ((hash[offset] & 0x7F) << 24)
| ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8)
| (hash[offset + 3] & 0xFF);
int otp = binary % (int) Math.pow(10, TOTP_DIGITS);
return String.format("%0" + TOTP_DIGITS + "d", otp);
} catch (Exception e) {
throw new MfaException("TOTP generation failed", e);
}
}
}
Session Management
Secure Session Configuration
// Spring Session with Redis
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800) // 30 minutes
public class SessionConfig {
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("SESSIONID");
serializer.setUseHttpOnlyCookie(true); // Prevent XSS
serializer.setUseSecureCookie(true); // HTTPS only
serializer.setSameSite("Strict"); // CSRF protection
serializer.setCookiePath("/");
serializer.setDomainNamePattern("^.+?\\.(\\w+\\.[a-z]+)$");
serializer.setCookieMaxAge(1800); // Match session timeout
return serializer;
}
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config =
new RedisStandaloneConfiguration("redis-host", 6379);
config.setPassword("redis-password");
return new LettuceConnectionFactory(config);
}
}
Session Lifecycle
The session lifecycle enforces security through controlled state transitions. Each request during Active state resets the idle timer, preventing premature timeouts. The 30-minute timeout for Idle sessions balances security with user experience - limiting exposure from abandoned sessions while avoiding disruption. The Terminated state ensures explicit logout immediately invalidates sessions, preventing token reuse.
Single Sign-On (SSO)
SSO Architecture
Related Topics
- Security Overview - Security principles and culture
- Authorization - Access control and permissions
- Data Protection - Encryption and data security
- Security Testing - Testing authentication flows
- Spring Boot Security - Framework implementation
Summary
Key Takeaways:
- OAuth 2.0: Use Authorization Code flow for web, PKCE for mobile/SPA, Client Credentials for service-to-service
- JWT: Short expiration (15 min), secure signing (HS512/RS256), validate all claims, never store secrets in payload
- MFA: Required for high-value transactions and sensitive operations
- Sessions: HttpOnly cookies, 30-minute timeout, secure transport, proper invalidation
- Token Refresh: Separate refresh tokens with longer expiration, validate before issuing new access token
- TOTP: Industry standard for MFA, compatible with Google Authenticator
- SSO: Single authentication across multiple applications, improves user experience
- Using long-lived JWTs (makes revocation difficult)
- Storing JWTs in localStorage (XSS vulnerability)
- Not validating token expiration and issuer
- Hardcoding secrets in source code
- Not implementing MFA for sensitive operations
- Allowing unlimited authentication attempts (brute force)