Hook: Interviewers love this question because many candidates treat OAuth2 and JWT like competing tools, when they actually solve different problems.
Question: OAuth2 vs JWT.
Answer: OAuth2 is an authorization framework: it defines how an app gets permission to call another app’s API. JWT is a token format: it is a compact string that can carry claims like the user, expiry, and scopes, and can be verified by signature. In Spring Boot, OAuth2 often handles the flow, while JWT is often the access token that the resource server validates.
Interview-Ready Answer: I’d say OAuth2 and JWT are not the same kind of thing. OAuth2 is the authorization framework that defines how a client gets delegated access, like authorization code or client credentials. JWT is a token format that can be used as an access token and validated locally by checking its signature and expiry. In Spring Boot, I often use OAuth2 for the flow and JWT as the token the resource server accepts.
OAuth2 is a delegation framework. In plain words, it answers: how does one app get limited access to another app without knowing the user’s password? A common example is a mobile app or browser app getting permission to call an API.
JWT means JSON Web Token. It is a token format, not a login system. A JWT contains claims (pieces of data, like sub for subject and exp for expiry) and is usually signed so the receiver can verify that nobody changed it.
The biggest interview trap is this: OAuth2 is the process; JWT is the package. OAuth2 can use a JWT access token, or it can use an opaque token (a random string with no readable content). JWT can also be used outside OAuth2.
Why this matters: JWT validation is fast because the resource server can do it locally. Opaque tokens are easier to revoke centrally, but each request may need a network call.
| Aspect | OAuth2 | JWT |
|---|---|---|
| What it is | Authorization framework | Token format |
| Main job | Delegated access flow | Carry signed claims |
| Can stand alone? | Yes | Yes |
| Typical use | Login/consent/API access | Access token or ID token |
| Validation | Flow + server rules | Signature + expiry |
| Spring Boot module | oauth2-client, oauth2-resource-server | Often same resource server, or custom JWT code |
JWT validation is roughly O(n) in token size because the server must parse the token and verify the signature, but the token is usually only 1-3 KB, so it feels close to constant time. In practice, local JWT validation is often in the microsecond-to-low-millisecond range, while opaque token introspection can add a 20-100 ms network hop.
Typical access tokens live about 5-15 minutes. Refresh tokens often live for days or weeks, depending on policy. Short-lived access tokens reduce damage if a token is stolen.
Important gotchas:
iss, aud, and exp. Signature alone is not enough.Memory hook: OAuth2 is the bouncer’s rulebook; JWT is the stamped wristband. The rulebook decides who gets in, and the wristband is the proof you can show at the door.
Real-World Example: Imagine an e-commerce checkout service in Spring Boot. The front end sends users to an identity provider using OAuth2 Authorization Code + PKCE. After login, the identity provider returns a JWT access token. The checkout API validates that token locally on every request, checks the expiry, and reads the user id and scopes from the claims.
Now the bug story: a team assumes “JWT means authenticated” and forgets to validate the aud claim and key rotation. A token minted for a staging app is accepted in production, or an old signing key keeps being trusted too long. Symptoms show up as either unexpected data access or a spike of 401 Unauthorized responses after deploy. Logs often show messages like invalid signature, expired token, or audience mismatch. Users see logout loops, broken checkout, or repeated login prompts until the config or key set is fixed.
package com.example.demo;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
@SpringBootApplication
public class OAuth2VsJwtDemoApplication {
public static void main(String[] args) {
SpringApplication.run(OAuth2VsJwtDemoApplication.class, args);
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
return http
// Stateless APIs usually disable CSRF because bearer tokens are sent in the Authorization header,
// not as browser cookies.
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login", "/api/public").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
JwtService jwtService(ObjectMapper objectMapper) {
return new JwtService(objectMapper, "change-me-in-prod-change-me-in-prod");
}
@Bean
JwtAuthenticationFilter jwtAuthenticationFilter(JwtService jwtService) {
return new JwtAuthenticationFilter(jwtService);
}
}
record LoginRequest(String username, String password) {}
@RestController
class AuthController {
private final JwtService jwtService;
private final Map<String, String> users = Map.of(
"alice", "password123",
"bob", "s3cret"
);
AuthController(JwtService jwtService) {
this.jwtService = jwtService;
}
@PostMapping("/auth/login")
ResponseEntity<Map<String, Object>> login(@RequestBody LoginRequest request) {
String expected = users.get(request.username());
if (expected == null || !expected.equals(request.password())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "bad_credentials"));
}
String token = jwtService.issueToken(request.username());
return ResponseEntity.ok(Map.of(
"access_token", token,
"token_type", "Bearer",
"expires_in_seconds", 900
));
}
}
@RestController
class ApiController {
@GetMapping("/api/public")
Map<String, String> publicEndpoint() {
return Map.of("message", "public endpoint");
}
@GetMapping("/api/me")
Map<String, Object> me(Authentication authentication) {
return Map.of(
"user", authentication.getName(),
"message", "JWT accepted"
);
}
}
class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
JwtAuthenticationFilter(JwtService jwtService) {
this.jwtService = jwtService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
// If no token is present, let Spring Security handle the anonymous/public path.
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7).trim();
Optional<String> subject = jwtService.validateAndGetSubject(token);
// This is the failure path interviewers care about: bad signature, bad format, or expired token.
if (subject.isEmpty()) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"invalid_or_expired_token\"}");
return;
}
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(subject.get(), null, Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
class JwtService {
private static final long TTL_SECONDS = 900;
private final ObjectMapper objectMapper;
private final SecretKeySpec keySpec;
JwtService(ObjectMapper objectMapper, String secret) {
this.objectMapper = objectMapper;
// HMAC keeps this demo self-contained. Real systems often use RSA or EC keys so resource servers
// can verify with a public key and the auth server keeps the private key.
this.keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
}
String issueToken(String subject) {
long now = Instant.now().getEpochSecond();
Map<String, Object> header = Map.of(
"alg", "HS256",
"typ", "JWT"
);
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("sub", subject);
payload.put("iat", now);
payload.put("exp", now + TTL_SECONDS);
payload.put("iss", "spring-boot-demo");
String headerPart = base64Url(json(header));
String payloadPart = base64Url(json(payload));
String signaturePart = sign(headerPart + "." + payloadPart);
return headerPart + "." + payloadPart + "." + signaturePart;
}
Optional<String> validateAndGetSubject(String token) {
try {
int firstDot = token.indexOf('.');
int secondDot = token.indexOf('.', firstDot + 1);
if (firstDot <= 0 || secondDot <= firstDot + 1 || token.indexOf('.', secondDot + 1) != -1) {
return Optional.empty();
}
String headerPart = token.substring(0, firstDot);
String payloadPart = token.substring(firstDot + 1, secondDot);
String signaturePart = token.substring(secondDot + 1);
String expectedSignature = sign(headerPart + "." + payloadPart);
if (!MessageDigest.isEqual(base64UrlDecode(expectedSignature), base64UrlDecode(signaturePart))) {
return Optional.empty();
}
Map<String, Object> header = readMap(headerPart);
if (!"HS256".equals(header.get("alg")) || !"JWT".equals(header.get("typ"))) {
return Optional.empty();
}
Map<String, Object> payload = readMap(payloadPart);
Object expValue = payload.get("exp");
Object subValue = payload.get("sub");
if (!(expValue instanceof Number) || !(subValue instanceof String)) {
return Optional.empty();
}
long exp = ((Number) expValue).longValue();
if (Instant.now().getEpochSecond() >= exp) {
return Optional.empty();
}
return Optional.of((String) subValue);
} catch (Exception ex) {
return Optional.empty();
}
}
private byte[] json(Map<String, Object> data) {
try {
return objectMapper.writeValueAsBytes(data);
} catch (IOException ex) {
throw new IllegalStateException("Could not serialize JWT data", ex);
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> readMap(String base64UrlPart) throws IOException {
return objectMapper.readValue(base64UrlDecode(base64UrlPart), Map.class);
}
private String sign(String signingInput) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(keySpec);
byte[] signed = mac.doFinal(signingInput.getBytes(StandardCharsets.UTF_8));
return base64Url(signed);
} catch (Exception ex) {
throw new IllegalStateException("Could not sign JWT", ex);
}
}
private String base64Url(byte[] bytes) {
return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private byte[] base64UrlDecode(String value) {
return java.util.Base64.getUrlDecoder().decode(value);
}
}Follow-up & Tricky Questions:
Tricky / gotcha questions:
Common Mistakes:
exp, iss, aud, and the signature.Memory Hook: OAuth2 is the rulebook; JWT is the ticket. The rulebook decides how permission is granted, and the ticket carries proof in a compact form.
Cheat Sheet:
Practice Tasks:
/auth/login endpoint in the sample and use the returned token on /api/me.role or scope and reject requests that do not have it.