JWT & Application Security — Interview Questions
Stack context: This system uses JWT signed by user-service (HS256 via jjwt 0.12.5), stored as a Redis session (
session:{jwt}, TTL 1800s). The api-gateway validates sessions by looking up the JWT in Redis. Downstream services receiveX-User-Id,X-User-Email,X-User-Roleheaders injected by the gateway. Spring Security filter chains secure each service.
Q1 — What is a JWT and what are its three parts? junior
Answer: JWT (JSON Web Token) is an open standard (RFC 7519) for securely transmitting claims between parties as a compact, URL-safe string.
Structure: header.payload.signature (three Base64URL-encoded parts, separated by dots)
1. Header:
{
"alg": "HS256", // signing algorithm
"typ": "JWT" // token type
}
2. Payload (claims):
{
"sub": "user-123-uuid", // subject (user ID)
"email": "user@example.com", // custom claim
"role": "USER", // custom claim
"iat": 1700000000, // issued at (Unix timestamp)
"exp": 1700003600, // expiration time (1 hour later)
"jti": "unique-token-id" // JWT ID (for revocation)
}
3. Signature:
HMACSHA256(
base64url(header) + "." + base64url(payload),
secretKey
)
Verification: The receiver recalculates the signature and compares it to the token's signature. If they match, the payload is authentic and untampered.
Important: JWT payload is Base64URL-encoded, NOT encrypted. Anyone can decode it. Never put sensitive data (passwords, PII beyond necessary) in JWT.
Q2 — What is the difference between HS256, RS256, and ES256 JWT signing algorithms? senior
Answer:
| Algorithm | Type | Key | Best for |
|---|---|---|---|
HS256 |
Symmetric HMAC | Shared secret (same key signs and verifies) | Monolith, microservices where all services share the secret |
RS256 |
Asymmetric RSA | Private key signs, public key verifies | Different services verify without the signing key |
ES256 |
Asymmetric ECDSA | Private key signs, public key verifies | Same as RS256 but smaller key size, faster |
HS256 (this system):
// user-service: sign with shared secret
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
String token = Jwts.builder().subject(userId).signWith(key, Jwts.SIG.HS256).compact();
// api-gateway: verify with same shared secret
Claims claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload();
RS256 (better for large systems):
// user-service: sign with PRIVATE key (kept secret)
token = Jwts.builder().signWith(rsaPrivateKey, Jwts.SIG.RS256).compact();
// Any service: verify with PUBLIC key (shared openly — no secret needed)
claims = Jwts.parser().verifyWith(rsaPublicKey).build().parseSignedClaims(token).getPayload();
Security: HS256 requires all services to know the secret — if any service is compromised, the signing capability is exposed. RS256 means only user-service holds the private key; other services only need the public key for verification.
Q3 — What are the standard JWT claims and their meanings? junior
Answer: The JWT spec (RFC 7519) defines registered claims. Custom claims can be added freely.
Registered claims (optional but standard):
| Claim | Name | Meaning |
|---|---|---|
iss |
Issuer | Who issued the token ("user-service") |
sub |
Subject | Token subject, typically user ID |
aud |
Audience | Intended recipient ("api-gateway") |
exp |
Expiration Time | Unix timestamp — token invalid after this |
nbf |
Not Before | Unix timestamp — token invalid before this |
iat |
Issued At | Unix timestamp of when token was issued |
jti |
JWT ID | Unique token identifier (for revocation) |
jjwt 0.12.5 example (this system):
String token = Jwts.builder()
.issuer("user-service")
.subject(user.getId().toString())
.claim("email", user.getEmail())
.claim("role", user.getRole().name())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 3_600_000)) // 1 hour
.id(UUID.randomUUID().toString()) // jti — for revocation tracking
.signWith(secretKey, Jwts.SIG.HS256)
.compact();
Q4 — How does this system's JWT session flow work end to end? senior
Answer: This system uses a hybrid approach: JWT + Redis session store. Pure stateless JWT + Redis for revocation.
Login flow:
1. POST /auth/login → user-service
2. user-service validates credentials (BCrypt compare)
3. user-service creates JWT (sub=userId, email, role, exp=+30min)
4. user-service stores session in Redis:
key: "session:{jwt_string}"
value: {userId, email, role}
TTL: 1800s
5. Returns JWT to client
Request flow:
1. Client → GET /orders → api-gateway (port 8080)
Authorization: Bearer <jwt>
2. api-gateway's AuthenticationFilter:
a. Extract JWT from Authorization header
b. Look up Redis: GET "session:{jwt}"
c. Redis HIT → extract userId, email, role
d. Set headers: X-User-Id, X-User-Email, X-User-Role
e. Forward request to order-service (port 8083)
3. order-service:
a. No JWT validation needed
b. Read X-User-Id header from gateway
c. Process request
4. Logout:
a. DELETE Redis key "session:{jwt}"
b. Token is immediately invalid (even if exp hasn't passed)
Why Redis + JWT?
- JWT alone: Cannot revoke before expiry.
- Redis session alone: Stateful, doesn't scale as easily.
- JWT + Redis: Redis acts as the source of truth for validity; JWT is just the lookup key.
Q5 — How do you handle JWT expiration and token refresh? senior
Answer: When a JWT expires, the user must re-authenticate (or use a refresh token flow).
Access token + Refresh token pattern:
// Login returns both tokens
record LoginResponse(String accessToken, String refreshToken) {}
// Access token: short-lived (15 min – 1 hour)
String accessToken = Jwts.builder()
.subject(userId)
.expiration(new Date(System.currentTimeMillis() + 900_000)) // 15 min
.signWith(key, Jwts.SIG.HS256).compact();
// Refresh token: long-lived (7 days), stored in DB/Redis
String refreshToken = UUID.randomUUID().toString(); // opaque token
redisTemplate.opsForValue().set("refresh:" + refreshToken, userId, Duration.ofDays(7));
Refresh endpoint:
@PostMapping("/auth/refresh")
public ResponseEntity<LoginResponse> refresh(@RequestBody RefreshRequest req) {
String userId = redisTemplate.opsForValue().get("refresh:" + req.refreshToken());
if (userId == null) throw new UnauthorizedException("Refresh token expired or invalid");
String newAccess = generateAccessToken(userId);
String newRefresh = rotateRefreshToken(req.refreshToken(), userId); // rotate for security
return ResponseEntity.ok(new LoginResponse(newAccess, newRefresh));
}
Token rotation: On each refresh, invalidate the old refresh token and issue a new one. This prevents refresh token theft reuse.
Q6 — How does Redis enable JWT revocation? senior
Answer: Pure stateless JWTs cannot be revoked before expiry. Redis solves this by making the token's validity dependent on a Redis lookup.
This system's approach (allowlist pattern):
// Validate token in api-gateway
public Mono<SessionData> validateToken(String jwt) {
String key = "session:" + jwt;
return redisTemplate.opsForHash()
.entries(key)
.collectMap(entry -> entry.getKey().toString(), entry -> entry.getValue().toString())
.flatMap(sessionData -> {
if (sessionData.isEmpty()) {
return Mono.error(new UnauthorizedException("Session not found or expired"));
}
return Mono.just(new SessionData(sessionData));
});
}
// Logout — immediate revocation
public void logout(String jwt) {
redisTemplate.delete("session:" + jwt); // token is immediately invalid
}
Alternative: blocklist pattern (for stateless systems):
// Only store revoked tokens (much smaller set)
public void revokeToken(String jti) {
long remainingTTL = getRemainingTTL(jwt); // time until natural expiry
redisTemplate.opsForValue().set("revoked:" + jti, "1", Duration.ofSeconds(remainingTTL));
}
// Validate
public boolean isRevoked(String jti) {
return Boolean.TRUE.equals(redisTemplate.hasKey("revoked:" + jti));
}
Trade-offs:
- Allowlist (this system): Every request hits Redis. Tokens are revoked immediately on logout or TTL expiry.
- Blocklist: Only revoked tokens in Redis. Smaller Redis dataset but can't invalidate tokens on server restart.
Q7 — What is OWASP Top 10 and which vulnerabilities are most relevant to this system? senior
Answer: OWASP Top 10 is the most widely referenced list of critical web application security risks.
Most relevant to this system:
A01: Broken Access Control
// VULNERABLE: user can access other users' orders
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable UUID id) {
return orderRepository.findById(id).orElseThrow(); // no ownership check!
}
// FIXED: verify ownership
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable UUID id,
@RequestHeader("X-User-Id") UUID requestUserId) {
Order order = orderRepository.findById(id).orElseThrow();
if (!order.getCustomerId().equals(requestUserId)) throw new ForbiddenException();
return order;
}
A02: Cryptographic Failures
- JWT secret must be at least 256 bits for HS256.
- Passwords stored with BCrypt (never MD5/SHA-1).
- HTTPS in production (TLS for all traffic).
A03: Injection
- JPA parameterized queries prevent SQL injection (see Q12).
A07: Identification and Authentication Failures
- Short-lived tokens, Redis session revocation.
- Rate limiting on
/auth/loginto prevent brute force.
A09: Security Logging and Monitoring Failures
- Log authentication failures (failed logins, invalid tokens).
- Alert on anomalous patterns (many 401s from one IP).
Q8 — What is Spring Security's filter chain architecture? senior
Answer:
Spring Security is implemented as a chain of Filter implementations. Each request passes through all filters in order.
Default filter order (simplified):
Request → DisableEncodeUrlFilter
→ SecurityContextPersistenceFilter
→ LogoutFilter
→ UsernamePasswordAuthenticationFilter (if form login)
→ BasicAuthenticationFilter (if basic auth)
→ BearerTokenAuthenticationFilter (if OAuth2/JWT)
→ ExceptionTranslationFilter
→ FilterSecurityInterceptor (authorization)
→ Controller
Custom filter for JWT validation (this system's api-gateway):
@Component
@Order(1)
public class JwtAuthenticationFilter implements WebFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String path = exchange.getRequest().getPath().value();
// Skip auth for public endpoints
if (path.startsWith("/auth/")) return chain.filter(exchange);
String authHeader = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
String jwt = authHeader.substring(7);
return sessionService.validateToken(jwt)
.flatMap(session -> {
ServerHttpRequest mutated = exchange.getRequest().mutate()
.header("X-User-Id", session.userId())
.header("X-User-Email", session.email())
.header("X-User-Role", session.role())
.build();
return chain.filter(exchange.mutate().request(mutated).build());
})
.onErrorResume(e -> {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
});
}
}
Q9 — What is CSRF and why is it disabled for REST APIs? junior
Answer: CSRF (Cross-Site Request Forgery) is an attack where a malicious website tricks a logged-in user's browser into making unauthorized requests to another site.
How CSRF works:
- User logs in to
bank.com— browser stores session cookie. - User visits
evil.comwhich has a hidden form:<form action="bank.com/transfer" method="POST">. - Browser automatically sends the bank's session cookie → unauthorized transfer executes.
Why CSRF protection is needed for session-cookie auth: The browser automatically sends cookies with cross-origin requests.
Why CSRF is NOT needed for JWT/header auth (this system):
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // Safe for JWT — JWT is in Authorization header
// Cookies are NOT used → cross-origin requests can't include the JWT
// Evil page cannot read or forge the Authorization header
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.build();
}
}
When to keep CSRF enabled: Traditional web apps using session cookies and form submission.
Q10 — What is CORS and what are the security implications? senior
Answer: CORS (Cross-Origin Resource Sharing) is a browser mechanism that restricts cross-origin HTTP requests. Browsers block responses from a different origin unless the server explicitly allows it via CORS headers.
CORS headers:
Access-Control-Allow-Origin: https://frontend.example.com (not wildcard for auth!)
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With
Access-Control-Max-Age: 3600
Spring Cloud Gateway CORS configuration (this system):
spring:
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]':
allowedOrigins:
- "https://frontend.example.com" # explicit origin, NOT *
allowedMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allowedHeaders: ["Authorization", "Content-Type"]
allowCredentials: true
maxAge: 3600
Security pitfall: allowedOrigins: "*" with allowCredentials: true is invalid (browsers block it). Never use * when sending cookies or Authorization headers.
Preflight: Browser sends an OPTIONS request before the actual request to check CORS permissions. The server must respond with appropriate headers for the actual request to proceed.
Q11 — How does BCrypt work and why is it preferred for password storage? junior
Answer: BCrypt is a password hashing function designed to be slow (adaptive cost), making brute-force attacks computationally expensive.
Properties:
- Adaptive cost factor (
strength): Each increment doubles computation time. Cost 12 = 2^12 = 4096 rounds. - Built-in salt: BCrypt generates a random salt and embeds it in the hash. No need to store salt separately.
- One-way: Cannot reverse the hash to get the password.
Spring Security BCrypt:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // strength 12 — ~200ms per hash (good balance)
}
// Registration
String hashedPassword = passwordEncoder.encode(rawPassword);
// Store hashedPassword in DB
// Login
boolean valid = passwordEncoder.matches(rawPassword, storedHashedPassword);
BCrypt hash format:
$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
^ ^ ^
| | |__ 22-char salt + 31-char hash
| |_____ cost factor (12)
|________ BCrypt version (2a)
Why NOT MD5/SHA: Fast (millions per second with GPU), no salt by default, susceptible to rainbow table attacks.
Q12 — How does JPA prevent SQL injection? junior
Answer: SQL injection occurs when user input is embedded directly into SQL strings, allowing attackers to alter the query structure.
Vulnerable (string concatenation):
// NEVER do this
String sql = "SELECT * FROM users WHERE email = '" + userInput + "'";
// Input: ' OR '1'='1 → returns all users!
// Input: '; DROP TABLE users; -- → destroys the table!
JPA/Hibernate uses parameterized queries automatically:
// Spring Data JPA method names → parameterized query internally
Optional<User> findByEmail(String email);
// Generates: SELECT * FROM users WHERE email = ? (? is bound safely)
// JPQL with named parameters
@Query("SELECT u FROM User u WHERE u.email = :email AND u.status = :status")
Optional<User> findActiveUser(@Param("email") String email, @Param("status") UserStatus status);
// ? binding prevents injection
Native queries (still safe with ? binding):
@Query(value = "SELECT * FROM users WHERE email = :email", nativeQuery = true)
Optional<User> findByEmailNative(@Param("email") String email);
// Still uses parameterized binding — SAFE
Remaining risk: Never concatenate parameters into native queries:
// DANGEROUS even in Spring Data JPA
@Query(value = "SELECT * FROM users ORDER BY " + sortField, nativeQuery = true) // BAD!
// Fix: whitelist allowed sort columns, never pass raw user input as column name
Q13 — What are secure HTTP headers and how do you add them in Spring Boot? junior
Answer: Secure HTTP response headers protect against various browser-based attacks.
| Header | Purpose | Example Value |
|---|---|---|
Strict-Transport-Security (HSTS) |
Force HTTPS | max-age=31536000; includeSubDomains |
X-Content-Type-Options |
Prevent MIME sniffing | nosniff |
X-Frame-Options |
Prevent clickjacking (iframe embedding) | DENY or SAMEORIGIN |
Content-Security-Policy (CSP) |
Restrict script/resource sources | default-src 'self' |
Referrer-Policy |
Control referer header | strict-origin-when-cross-origin |
Permissions-Policy |
Disable browser features | geolocation=(), camera=() |
Spring Security adds many by default. Customize in SecurityFilterChain:
http.headers(headers -> headers
.frameOptions(frame -> frame.deny())
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self'; frame-ancestors 'none'"))
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000)
.includeSubDomains(true))
);
REST API headers (Spring Security defaults): X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and Cache-Control: no-cache, no-store are applied automatically.
Q14 — What is rate limiting as a security control and how is it implemented? senior
Answer: Rate limiting prevents brute force attacks, credential stuffing, and DoS/DDoS by restricting the number of requests per user/IP per time window.
Security use cases:
- Login endpoint: Max 5 attempts per minute per IP → prevents brute-forcing passwords.
- Password reset: Max 3 resets per hour per email → prevents account enumeration.
- API endpoints: Max N requests per second per user → prevents scraping and DoS.
This system's rate limiting (api-gateway, token bucket):
spring:
cloud:
gateway:
routes:
- id: user-service
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10 # 10 requests/second
redis-rate-limiter.burstCapacity: 20 # burst up to 20
key-resolver: "#{@userKeyResolver}" # rate limit per user
@Bean
KeyResolver userKeyResolver() {
return exchange -> Mono.justOrEmpty(
exchange.getRequest().getHeaders().getFirst("X-User-Id")
).defaultIfEmpty("anonymous");
}
Response headers:
X-RateLimit-Remaining: 5
X-RateLimit-Replenish-Rate: 10
HTTP/1.1 429 Too Many Requests
Retry-After: 1
Q15 — What is IDOR (Insecure Direct Object Reference) and how do you prevent it? senior
Answer: IDOR (A01: Broken Access Control) occurs when an application exposes internal object IDs that an attacker can manipulate to access resources belonging to other users.
Vulnerable example:
// IDOR: GET /orders/123 — user A can access user B's order by guessing the ID
@GetMapping("/orders/{orderId}")
public Order getOrder(@PathVariable UUID orderId) {
return orderRepository.findById(orderId).orElseThrow(); // no ownership check!
}
Fixed example:
@GetMapping("/orders/{orderId}")
public Order getOrder(@PathVariable UUID orderId,
@RequestHeader("X-User-Id") String userId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new ResourceNotFoundException("Order not found"));
// Verify ownership — user can only see their own orders
if (!order.getCustomerId().toString().equals(userId)) {
throw new ForbiddenException("Access denied"); // or return 404 to avoid leaking existence
}
return order;
}
UUID vs sequential IDs: UUIDs are harder to enumerate (not sequential), but are NOT a security control — never rely on ID unpredictability as a security measure. Always check ownership.
Spring Security @PreAuthorize (role + ownership):
@PreAuthorize("@orderSecurityService.isOwner(#orderId, authentication.principal)")
@GetMapping("/orders/{orderId}")
public Order getOrder(@PathVariable UUID orderId) { ... }
Q16 — What is the principle of least privilege and how is it applied in this system? junior
Answer: Principle of least privilege: grant only the minimum permissions required to perform a task. Reduce attack surface by not granting unnecessary access.
Applied at different layers:
Database level:
-- Each service has its own DB user with limited permissions
CREATE USER order_service_user WITH PASSWORD '...';
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO order_service_user;
-- NOT: GRANT ALL PRIVILEGES / SUPERUSER
-- NOT: shared DB user across all services
Kafka level:
order-service: WRITE to order.created, READ from payment.processed
payment-service: READ from order.created, WRITE to payment.processed
-- Not all services can write to all topics
Application level (Spring Security):
http.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll() // public
.requestMatchers(HttpMethod.POST, "/api/v1/orders/**").hasRole("USER")
.requestMatchers(HttpMethod.DELETE, "/api/v1/orders/**").hasRole("ADMIN")
.anyRequest().authenticated()
);
Container level: Containers run as non-root users. Read-only filesystem where possible.
Q17 — How do you implement audit logging for security events? senior
Answer: Audit logging records security-relevant events for forensic investigation, compliance, and anomaly detection.
Security events to log:
- Successful login / logout.
- Failed login attempts (with IP, user agent).
- Password changes.
- JWT validation failures (invalid token, expired, not found in Redis).
- Authorization failures (403).
- Sensitive data access (payment details, PII).
- Administrative actions.
Implementation (Spring AOP + SLF4J):
@Aspect
@Component
public class SecurityAuditAspect {
private static final Logger auditLog = LoggerFactory.getLogger("AUDIT");
@Around("@annotation(audited)")
public Object auditMethod(ProceedingJoinPoint pjp, Audited audited) throws Throwable {
String userId = SecurityContextHolder.getContext().getAuthentication() != null
? SecurityContextHolder.getContext().getAuthentication().getName()
: "anonymous";
try {
Object result = pjp.proceed();
auditLog.info("action={} user={} status=SUCCESS", audited.action(), userId);
return result;
} catch (Exception e) {
auditLog.warn("action={} user={} status=FAILURE reason={}", audited.action(), userId, e.getMessage());
throw e;
}
}
}
// Usage
@Audited(action = "ORDER_CREATE")
public Order createOrder(CreateOrderRequest request) { ... }
Structured audit log entry (JSON):
{
"timestamp": "2026-01-15T10:30:00Z",
"level": "INFO",
"logger": "AUDIT",
"action": "LOGIN_FAILED",
"userId": null,
"email": "user@test.com",
"ipAddress": "192.168.1.100",
"userAgent": "Mozilla/5.0...",
"reason": "Invalid password",
"traceId": "abc123"
}
Q18 — What is information disclosure in error messages and how do you prevent it? junior
Answer: Information disclosure (OWASP A05) occurs when error messages reveal sensitive implementation details (stack traces, DB schema, internal IPs) that help attackers.
Vulnerable (Spring Boot default without configuration):
{
"timestamp": "2026-01-15T10:30:00.000+00:00",
"status": 500,
"error": "Internal Server Error",
"trace": "org.postgresql.util.PSQLException: ERROR: column \"emaill\" of relation \"users\" does not exist\n\tat org.postgresql.jdbc.PgPreparedStatement...",
"path": "/api/v1/users"
}
This exposes: table name users, column name emaill, PostgreSQL version, internal class names.
Secure error handling:
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex, HttpServletRequest req) {
// Log full details internally
log.error("Unexpected error on {} {}", req.getMethod(), req.getRequestURI(), ex);
// Return generic message to client
return ResponseEntity.status(500)
.body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
}
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
return ResponseEntity.status(404)
.body(new ErrorResponse("NOT_FOUND", ex.getMessage()));
}
}
Spring Boot configuration (disable full error details):
server:
error:
include-stacktrace: never
include-message: never # Spring Boot 2.3+ hides messages by default
include-binding-errors: never
Q19 — How do you securely manage JWT secrets and rotate them? senior
Answer: JWT signing secrets are critical — anyone with the secret can forge valid tokens.
Secret requirements:
- HS256: At least 256 bits (32 bytes) of random entropy.
- Generated with a CSPRNG (Cryptographically Secure Pseudo-Random Number Generator).
- Never hardcoded in source code or committed to git.
Generating a secure secret:
# Generate 512-bit (64-byte) random secret
openssl rand -base64 64
# Or with Java
SecureRandom.getInstanceStrong().nextBytes(new byte[64])
Configuration (environment variable, not in application.yml):
# application.yml
jwt:
secret: ${JWT_SECRET} # injected from environment / secrets manager
expiration: 3600 # seconds
# application-test.yml (test-only secret)
jwt:
secret: test-secret-key-minimum-256-bits-long-for-hmac-sha256
Secret rotation strategy:
- Generate new secret.
- Deploy all services with BOTH old and new secrets (accept either).
- Issue all new tokens with new secret.
- Wait for old tokens to expire naturally (TTL = 1800s in this system).
- Remove old secret from configuration.
For zero-downtime rotation: Use RS256 — publish new public key in a JWKS endpoint. Services update the JWKS periodically. Rotate the private key without downtime.
Q20 — What is the difference between authentication and authorization? junior
Answer:
| Authentication (AuthN) | Authorization (AuthZ) | |
|---|---|---|
| Question | "Who are you?" | "What are you allowed to do?" |
| Mechanism | JWT verification, session lookup | Role checks, permission checks |
| When | First, before authorization | After authentication |
| This system | Redis session lookup in api-gateway | X-User-Role header checked by downstream services |
This system's flow:
Authentication: api-gateway checks Redis → "this JWT belongs to userId=123, role=USER" ✓
Authorization: order-service checks "is role USER allowed to create orders?" ✓
order-service checks "does userId=123 own orderId=456?" ✓
Spring Security:
// Authentication — verify who you are
http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
// Authorization — verify what you can do
http.authorizeHttpRequests(auth -> auth
.requestMatchers(POST, "/orders").hasRole("USER") // authenticated users can order
.requestMatchers(DELETE, "/orders/**").hasRole("ADMIN") // only admins can delete
.requestMatchers(GET, "/actuator/**").hasRole("ADMIN") // actuator for admins only
);
Q21 — How do you prevent brute force attacks on the login endpoint? senior
Answer: Brute force attacks try many password combinations. Without protection, an attacker can eventually find the correct password.
Defense-in-depth approach:
1. Rate limiting (this system — token bucket):
# In api-gateway, stricter limits for auth endpoints
- id: login-route
uri: lb://user-service
predicates:
- Path=/auth/login
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 5 # 5 attempts per second
redis-rate-limiter.burstCapacity: 5 # no burst for login
key-resolver: "#{@ipKeyResolver}" # rate limit per IP
2. Account lockout (after N failures):
@Transactional
public User attemptLogin(String email, String password) {
User user = userRepo.findByEmail(email).orElseThrow(() -> new UnauthorizedException("Invalid credentials"));
if (user.getFailedAttempts() >= 5 && user.getLockoutUntil().isAfter(Instant.now())) {
throw new AccountLockedException("Account locked. Try again after " + user.getLockoutUntil());
}
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
user.incrementFailedAttempts();
if (user.getFailedAttempts() >= 5) user.lockFor(Duration.ofMinutes(15));
userRepo.save(user);
throw new UnauthorizedException("Invalid credentials"); // same message as "user not found"
}
user.resetFailedAttempts();
return user;
}
3. Constant-time responses: Always check the password hash even if user not found (prevents timing attacks):
if (user == null) {
passwordEncoder.matches(password, "$2a$12$dummy_hash_to_prevent_timing_attack");
throw new UnauthorizedException("Invalid credentials");
}
Q22 — What is the jjwt 0.12.x API and how does it differ from older versions? junior
Answer: jjwt 0.12.x introduced a new, fluent builder API and deprecated older methods.
Old API (0.11.x):
// DEPRECATED
String token = Jwts.builder()
.setSubject(userId)
.setExpiration(expiry)
.signWith(SignatureAlgorithm.HS256, secretBytes)
.compact();
Jws<Claims> jws = Jwts.parserBuilder()
.setSigningKey(secretBytes)
.build()
.parseClaimsJws(token);
New API (0.12.x) (this system):
// Build token
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
String token = Jwts.builder()
.subject(userId) // was .setSubject()
.issuedAt(new Date()) // was .setIssuedAt()
.expiration(expiry) // was .setExpiration()
.claim("role", role)
.signWith(key, Jwts.SIG.HS256) // was SignatureAlgorithm.HS256
.compact();
// Parse and verify token
Claims claims = Jwts.parser() // was .parserBuilder()
.verifyWith(key) // was .setSigningKey()
.build()
.parseSignedClaims(token) // was .parseClaimsJws()
.getPayload(); // was .getBody()
String userId = claims.getSubject();
String role = claims.get("role", String.class);
Key changes: All set* methods → fluent names; Jwts.SIG for algorithm constants; parseSignedClaims instead of parseClaimsJws.
Q23 — How do you store tokens securely on the client side? senior
Answer: Token storage on the client affects security against XSS (Cross-Site Scripting) and CSRF attacks.
Storage options:
| Storage | XSS risk | CSRF risk | Recommendation |
|---|---|---|---|
localStorage |
High (JS can read) | None (not sent automatically) | Avoid for sensitive tokens |
sessionStorage |
High (JS can read) | None | Slightly better (cleared on tab close) |
httpOnly Cookie |
None (JS cannot read) | Requires CSRF protection | Best for web apps |
| In-memory (JS variable) | Medium (page reload = logged out) | None | Good for SPAs |
This system (API-first design — no web UI):
- Mobile apps: Store in secure keychain/keystore (OS-level secure storage).
- SPAs: Use
httpOnlycookies or in-memory with token refresh. - API clients: Authorization header with in-memory token.
Best practices for web SPAs:
Access token: In-memory JavaScript variable (lost on page refresh)
Refresh token: httpOnly cookie (not accessible to JS, CSRF-protected)
On page load: Use refresh token (from httpOnly cookie) to get new access token
What NOT to do:
localStorage.setItem('jwt', token)— accessible to any XSS payload on the page.
Q24 — What is a security context in Spring Security and how is it propagated? senior
Answer:
SecurityContext holds the current user's Authentication object. Spring Security stores it in SecurityContextHolder (thread-local by default).
// Get current user from SecurityContext
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String userId = auth.getName(); // "sub" claim
Collection<?> roles = auth.getAuthorities(); // ["ROLE_USER"]
This system's pattern (downstream services — no JWT, uses injected headers):
// SecurityConfig in order-service — trusts X-User-Id from gateway
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.addFilterBefore(new TrustedHeaderAuthFilter(), UsernamePasswordAuthenticationFilter.class)
.csrf(csrf -> csrf.disable())
.build();
}
}
// Extract user from trusted gateway headers
public class TrustedHeaderAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) {
String userId = req.getHeader("X-User-Id");
String role = req.getHeader("X-User-Role");
if (userId != null) {
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
userId, null, List.of(new SimpleGrantedAuthority("ROLE_" + role)));
SecurityContextHolder.getContext().setAuthentication(auth);
}
chain.doFilter(req, res);
SecurityContextHolder.clearContext(); // always clear after request
}
}
Q25 — How do you secure microservice-to-microservice communication? senior
Answer:
Downstream services trust headers injected by the api-gateway. But what prevents a malicious actor from calling order-service directly with forged X-User-Id headers?
Defense strategies:
1. Network-level restriction: Downstream services are NOT exposed to the public internet — only reachable within the Docker/Kubernetes network. Only api-gateway is exposed.
services:
order-service:
# No ports: directive — not accessible from outside Docker network
expose:
- "8083" # accessible only within ecommerce-network
api-gateway:
ports:
- "8080:8080" # only the gateway is public
2. mTLS (Mutual TLS): Both client and server authenticate with certificates. Only api-gateway has the cert to call downstream services.
3. Internal API keys: Downstream services require a shared secret header from gateway:
// Gateway injects internal key
headers.add("X-Internal-Api-Key", internalApiKey);
// Order-service validates it
if (!internalApiKey.equals(request.getHeader("X-Internal-Api-Key"))) {
response.sendError(403, "Forbidden");
}
4. Service mesh (Istio, Linkerd): Automatic mTLS between all services, no code changes needed.
Q26 — What is token-based authentication vs session-based authentication? junior
Answer:
| Session-based | Token-based (JWT) | |
|---|---|---|
| State | Server-side session store | Stateless (token carries data) |
| Scalability | Sticky sessions or shared session store | Horizontal scaling without shared store |
| Revocation | Delete session → immediate revocation | Need blocklist or Redis (see Q6) |
| Storage (client) | Session cookie (httpOnly) | localStorage, sessionStorage, or header |
| Bandwidth | Small (session ID) | Larger (token contains claims) |
| CSRF | Cookie-based → needs CSRF protection | Header-based → CSRF not needed |
This system: Hybrid — uses JWT as a session key in Redis. Combines:
- Token-based: JWT signed, carries user claims.
- Session-based: Redis stores session state, enables instant revocation.
Pure stateless JWT (without Redis):
- Pro: No Redis needed, truly scalable.
- Con: Cannot revoke before expiry. If a token is stolen, it's valid until it expires.
Q27 — What is OAuth2 and how does it differ from JWT? junior
Answer: OAuth2 is an authorization framework (not a protocol) that enables a third-party application to obtain limited access to a service on behalf of a user, without sharing credentials.
JWT is a token format — not an authorization framework. JWTs can be used within OAuth2 flows (as access tokens) or independently.
OAuth2 roles:
- Resource Owner: The user.
- Client: App requesting access (e.g., a mobile app).
- Authorization Server: Issues tokens after user consent (Keycloak, Auth0, Okta).
- Resource Server: The API being protected (order-service).
OAuth2 flow (Authorization Code):
1. Client redirects user → Authorization Server (AS)
2. User authenticates and consents at AS
3. AS redirects back with authorization code
4. Client exchanges code for access token (+ refresh token) at AS
5. Client calls Resource Server with access token
6. Resource Server validates token (local JWT validation or token introspection)
This system: Does NOT use OAuth2 — implements its own auth with JWT + Redis. OAuth2 would be appropriate for third-party integrations, social login, or when token delegation is needed.
Q28 — How do you test security configurations in Spring Boot? senior
Answer: Security tests verify that access controls are correctly enforced — not just the happy path.
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class) // import real security config
class OrderControllerSecurityTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService;
@MockBean SessionValidationService sessionService;
// Test unauthenticated access
@Test
void shouldReturn401WhenNoAuthHeader() throws Exception {
mockMvc.perform(get("/api/v1/orders"))
.andExpect(status().isUnauthorized());
}
// Test expired/invalid token
@Test
void shouldReturn401WhenTokenNotInRedis() throws Exception {
when(sessionService.validate(any())).thenReturn(Mono.empty());
mockMvc.perform(get("/api/v1/orders")
.header("Authorization", "Bearer expired.jwt.token"))
.andExpect(status().isUnauthorized());
}
// Test insufficient role
@Test
void shouldReturn403WhenUserAccessesAdminRoute() throws Exception {
when(sessionService.validate(any())).thenReturn(Mono.just(new SessionData("user-1", "USER")));
mockMvc.perform(delete("/api/v1/admin/orders/some-id")
.header("Authorization", "Bearer valid.user.jwt"))
.andExpect(status().isForbidden());
}
// Test success
@Test
void shouldReturn200WhenValidToken() throws Exception {
when(sessionService.validate(any())).thenReturn(Mono.just(new SessionData("user-1", "USER")));
when(orderService.getOrders(any())).thenReturn(List.of());
mockMvc.perform(get("/api/v1/orders")
.header("Authorization", "Bearer valid.jwt"))
.andExpect(status().isOk());
}
}
Q29 — What is header injection and how do you prevent it in the api-gateway? senior
Answer:
Header injection occurs when an attacker sends forged downstream headers (e.g., X-User-Id: admin-user) directly to the api-gateway, attempting to impersonate another user.
The vulnerability:
// VULNERABLE: if gateway doesn't strip incoming headers before adding its own,
// a direct request to http://gateway/orders with header "X-User-Id: admin-123"
// would pass that header downstream — privilege escalation!
Prevention in Spring Cloud Gateway:
@Component
public class RemoveTrustedHeadersFilter implements GlobalFilter, Ordered {
// Strip incoming headers that are set by the gateway
private static final List<String> GATEWAY_HEADERS = List.of(
"X-User-Id", "X-User-Email", "X-User-Role", "X-Internal-Api-Key"
);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// Remove any incoming trusted headers from the external request
ServerHttpRequest sanitized = exchange.getRequest().mutate()
.headers(headers -> GATEWAY_HEADERS.forEach(headers::remove))
.build();
return chain.filter(exchange.mutate().request(sanitized).build());
}
@Override
public int getOrder() { return Ordered.HIGHEST_PRECEDENCE; } // run first!
}
This ensures that trusted headers are ONLY set by the gateway's own auth filter, never passed through from the client.
Q30 — What is a security design review checklist for a microservices system? senior
Answer: A security design review ensures security controls are in place before deployment.
Authentication & Authorization:
- All public endpoints explicitly allowed; all others require authentication.
- JWT secret is at least 256 bits, stored in environment variable / secrets manager.
- JWT expiry is short (≤1 hour); refresh token rotation implemented.
- Token revocation via Redis session invalidation on logout.
- Downstream services strip and re-inject trusted headers (prevent header injection).
- All endpoints enforce ownership checks (prevent IDOR).
Input Validation:
- All user input validated with Bean Validation (
@NotNull,@Size,@Email). - JPA parameterized queries used everywhere (no SQL injection).
- File upload size limits and type validation if applicable.
Transport Security:
- TLS/HTTPS for all external traffic.
- HSTS header configured.
- CORS configured with explicit allowed origins (no
*with credentials).
Infrastructure:
- Downstream services not exposed to public internet (only api-gateway).
- Containers run as non-root user.
- Database users have least-privilege permissions.
- Secrets never in source code or Docker images.
Operational:
- Security events logged (login failures, auth errors, access denied).
- Rate limiting on auth endpoints.
- Docker images scanned for CVEs in CI pipeline.
- Error messages don't expose internal details.
- Spring Boot Actuator secured (requires ADMIN role or network restriction).