Spring Security JWT OAuth2 Interview Questions

  • Last Updated: July 31, 2026
  • By: javahandson
  • Series
img

Spring Security JWT OAuth2 Interview Questions

Master spring security jwt oauth2 interview questions: @PreAuthorize, CSRF, session policy, JWT filters, refresh tokens, and OAuth2 flow with code.

Introduction

Preparing for spring security jwt oauth2 interview questions means going beyond simply logging a user in — it means understanding how Spring decides who is allowed to do what, how a stateless token replaces the server session, and how an external identity provider hands your application a trusted token. Authorization, JWT and OAuth2 are the three areas where product-company interviewers separate developers who copy a security config from Stack Overflow from those who can reason about the filter chain, the SecurityContext, and the token lifecycle under production pressure.

This article covers 15 carefully crafted spring security jwt oauth2 interview questions — from beginner to advanced — spanning role-based and permission-based authorization, method security annotations, CSRF and CORS in a security context, stateless session management, the complete JWT authentication flow, the refresh token pattern, the OAuth2 Authorization Code flow, resource server configuration, and the major changes in Spring Security 6.x. Every answer includes the technical depth and Interview Insight notes that product companies expect from senior Spring developers.

📌 How to use this article: Read each question as if the interviewer just asked it. Then read the answer. The Interview Insight notes at the end of each answer highlight the specific detail that separates average candidates from strong ones.

Beginner Level — Q1 to Q4

Q1. What is role-based vs permission-based authorization?

Answer – Authorization answers a single question: now that we know who the user is, what are they allowed to do? Spring Security supports two broad models for answering it. Role-based authorization grants access based on coarse-grained roles such as ROLE_ADMIN or ROLE_USER. A role is essentially a label attached to a group of users, and endpoints are protected by checking whether the authenticated user carries that label. This model is simple and reads naturally, which is why most applications start here.

Permission-based (also called authority-based) authorization is finer-grained. Instead of asking ‘is this user an admin?’, you ask ‘does this user hold the ORDER_DELETE authority?’. Permissions describe specific actions rather than broad job titles, so a single user might hold dozens of permissions granted through one or more roles. This decouples what a user can do from who they are, which scales far better as an application grows and as the number of distinct protected actions multiplies.

In Spring Security, both roles and permissions are represented by the same underlying type: GrantedAuthority. The only difference is a naming convention. A role is simply an authority whose name is prefixed with ROLE_. When you call hasRole(“ADMIN”), Spring internally looks for an authority named ROLE_ADMIN. When you call hasAuthority(“ORDER_DELETE”), Spring looks for that exact string with no prefix added. Understanding that roles are just prefixed authorities removes most of the confusion candidates have around this topic.

Configuring both models in HttpSecurity

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain chain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> auth
            // ROLE-BASED: hasRole("ADMIN") looks for authority ROLE_ADMIN
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .requestMatchers("/reports/**").hasAnyRole("ADMIN", "MANAGER")

            // PERMISSION-BASED: hasAuthority uses the exact string, no prefix
            .requestMatchers(HttpMethod.DELETE, "/orders/**").hasAuthority("ORDER_DELETE")
            .requestMatchers(HttpMethod.POST,   "/orders/**").hasAuthority("ORDER_CREATE")

            .anyRequest().authenticated()
        );
        return http.build();
    }
}
AspectRole-basedPermission-based
GranularityCoarse (job title)Fine (specific action)
ExampleROLE_ADMIN, ROLE_USERORDER_DELETE, USER_EDIT
PrefixROLE_ (added by hasRole)None (exact string)
Config methodhasRole / hasAnyRolehasAuthority / hasAnyAuthority
Scales to large appsPoorly — role explosionWell — permissions per role
Best forSmall apps, simple accessEnterprise, granular control
🎯 Interview Insight: The single most common mistake candidates make is calling hasRole(“ROLE_ADMIN”). hasRole() adds the ROLE_ prefix automatically, so this actually looks for an authority named ROLE_ROLE_ADMIN, which never matches. Pass the role name without the prefix to hasRole(), but store the authority with the prefix in your UserDetails. Say this out loud in an interview and you immediately signal that you have debugged real Spring Security config.

Q2. What is @PreAuthorize vs @PostAuthorize vs @Secured vs @RolesAllowed?

Answer – These four annotations all enable method-level security — securing individual service or controller methods rather than URL patterns — but they differ in expressiveness and in when the check runs relative to method execution. Method security complements URL-based security: URL rules guard the front door, while method annotations guard the business logic even if it is invoked from somewhere the URL rules never see.

@PreAuthorize is the most powerful and most widely used. It evaluates a Spring Expression Language (SpEL) expression before the method executes. Because it accepts full SpEL, it can reference method arguments, the authenticated principal, and call custom bean methods. @PostAuthorize evaluates its SpEL expression after the method returns, which lets you make the decision based on the returned object using the special returnObject variable — useful when you can only tell whether access is allowed after loading the data. @Secured and @RolesAllowed are simpler: they take a plain list of roles and do not support SpEL, so they can only perform basic role checks.

Enabling method security

@Configuration
@EnableMethodSecurity   // enables @PreAuthorize / @PostAuthorize by default
public class MethodSecurityConfig { }

The four annotations in practice

@Service
public class OrderService {

    // @PreAuthorize: SpEL, evaluated BEFORE the method runs
    @PreAuthorize("hasRole('ADMIN') and #order.amount < 10000")
    public void approve(Order order) { /* ... */ }

    // Reference the authenticated principal and a method argument
    @PreAuthorize("#userId == authentication.principal.id")
    public Account getAccount(Long userId) { /* only your own account */ }

    // @PostAuthorize: evaluated AFTER return, can inspect returnObject
    @PostAuthorize("returnObject.owner == authentication.name")
    public Document loadDocument(Long id) { /* ... */ }

    // @Secured: plain roles, no SpEL, must use ROLE_ prefix
    @Secured("ROLE_ADMIN")
    public void deleteAll() { /* ... */ }

    // @RolesAllowed: JSR-250 standard equivalent of @Secured
    @RolesAllowed({"ADMIN", "MANAGER"})
    public void archive() { /* ... */ }
}
AnnotationWhenSpELNotes
@PreAuthorizeBefore methodYesMost powerful; args + principal
@PostAuthorizeAfter methodYesUses returnObject to decide
@SecuredBefore methodNoPlain roles; needs ROLE_ prefix
@RolesAllowedBefore methodNoJSR-250 standard; portable

When to Use Which

  • Use @PreAuthorize for almost everything: it is the modern default and handles roles, authorities, argument checks, and ownership checks through SpEL.
  • Use @PostAuthorize only when the access decision genuinely depends on the returned object — for example, verifying the caller owns the record you just loaded.
  • Use @RolesAllowed when you want a vendor-neutral JSR-250 annotation that would survive a move away from Spring Security.
  • Avoid @Secured in new code: it offers nothing @PreAuthorize does not, and its lack of SpEL makes it strictly less capable.
🎯 Interview Insight: A sharp follow-up is: ‘What is the performance cost of @PostAuthorize?’ Because it runs after the method, the method has already executed and loaded data before access is denied. For a read that is expensive or has side effects, that work is wasted when authorization fails — and worse, if the method mutates state, @PostAuthorize will not undo it. Prefer @PreAuthorize with an argument check whenever the decision can be made before execution.

Q3. What is @EnableMethodSecurity and how does it differ from @EnableGlobalMethodSecurity?

Answer – Both annotations switch on method-level security, but @EnableGlobalMethodSecurity is the older mechanism and @EnableMethodSecurity is its modern replacement, introduced in Spring Security 5.6 and the standard choice in Spring Security 6. If an interviewer asks about this, they are usually checking whether you are current with the framework or still writing configuration from tutorials that are several years old.

The older @EnableGlobalMethodSecurity required you to explicitly flip individual flags — prePostEnabled, securedEnabled, jsr250Enabled — to turn on the corresponding annotation families. It was built on a metadata-source and voter-based architecture (AccessDecisionManager and AccessDecisionVoter) that was flexible but verbose and hard to extend. @EnableMethodSecurity replaces that voter model with the simpler, more composable AuthorizationManager API. It also enables prePostEnabled by default, so @PreAuthorize and @PostAuthorize work with zero extra configuration.

Aspect@EnableGlobalMethodSecurity@EnableMethodSecurity
StatusDeprecated (legacy)Current (6.x default)
Underlying APIAccessDecisionManager / votersAuthorizationManager
Pre/Post defaultOff — must set prePostEnabledOn by default
ExtensibilityCustom votersCustom AuthorizationManager
IntroducedSpring Security 3.x eraSpring Security 5.6

Migrating from old to new

// OLD — legacy, verbose
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class OldConfig { }

// NEW — prePostEnabled is on by default
@EnableMethodSecurity
public class NewConfig { }

// Enable @Secured or JSR-250 explicitly if you still need them
@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true)
public class NewConfigWithExtras { }
🎯 Interview Insight: The deeper point to make is architectural: the move from AccessDecisionManager/voters to AuthorizationManager is the same simplification Spring Security applied across the whole framework in 6.x. AuthorizationManager is a single functional interface returning an AuthorizationDecision, which is far easier to compose and unit test than assembling a list of voters and a decision manager. Mentioning that you understand this is a broader direction of the framework — not just one annotation swap — signals real depth.

Q4. What is CSRF — what it is, when to disable it, and how CsrfTokenRepository works?

Answer – Cross-Site Request Forgery (CSRF) is an attack where a malicious site tricks a victim’s browser into sending an authenticated request to your application without the victim’s intent. Because browsers automatically attach cookies to requests, a hidden form or image tag on an attacker’s page can cause the victim’s browser to submit a state-changing request — transfer money, change an email — using the victim’s existing session cookie. The server cannot tell the forged request apart from a legitimate one because the cookie is valid.

Spring Security defends against this with the synchronizer token pattern. On each session, the server generates a secret CSRF token and requires that token to be echoed back on every state-changing request (POST, PUT, PATCH, DELETE). An attacker’s page cannot read this token because of the browser’s same-origin policy, so it cannot forge a valid request. CSRF protection is enabled by default in Spring Security and applies to unsafe HTTP methods only; safe methods like GET are exempt because they should not change state.

When to disable CSRF

The decision hinges on how clients authenticate. CSRF attacks rely on the browser automatically sending a credential — a session cookie. If your API is stateless and authenticates with a bearer token (JWT) sent in the Authorization header, the browser does not attach that token automatically, so a forged cross-site request carries no credential and CSRF protection adds no value. This is why CSRF is typically disabled for stateless REST APIs and kept enabled for traditional server-rendered, session-cookie applications.

// STATELESS JWT API — CSRF adds no protection, disable it
@Bean
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
    return http.build();
}

// SESSION-COOKIE APP — keep CSRF on; expose the token to the JS client via a cookie
@Bean
SecurityFilterChain webChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf
        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()));
    return http.build();
}

CsrfTokenRepository is the strategy interface that decides where the expected CSRF token is stored and how it is loaded on the next request. Spring ships two implementations. HttpSessionCsrfTokenRepository (the default) stores the token in the HTTP session on the server. CookieCsrfTokenRepository stores the token in a cookie instead, which is the standard choice for single-page applications: the JavaScript client reads the token from the cookie and sends it back in a request header, letting the server compare the two. The withHttpOnlyFalse() factory makes the cookie readable by JavaScript so the SPA can access it.

🎯 Interview Insight: A precise answer distinguishes ‘disable CSRF’ from ‘CSRF is unnecessary’. For a pure token-in-header API, disabling CSRF is correct because the attack vector does not apply. But if any part of your app authenticates with cookies — even a legacy login page — you must keep CSRF enabled for those routes. The nuanced position is to split the configuration: a stateless SecurityFilterChain for /api/** with CSRF off, and a separate session-based chain for the web UI with CSRF on.

Intermediate Level — Q5 to Q11

Q5. What is CORS in Spring Security — @CrossOrigin vs global config vs Security config?

Answer – Cross-Origin Resource Sharing (CORS) is a browser mechanism that controls whether a web page served from one origin may make requests to a different origin. It is enforced by the browser, not by your server directly — the browser sends a preflight OPTIONS request for non-simple requests and refuses the actual request unless the server responds with the right Access-Control headers. In a Spring application there are three places CORS can be configured, and the interaction between them and the security filter chain trips up many developers.

@CrossOrigin is the most local option: placed on a controller or handler method, it declares allowed origins for just those endpoints. Global MVC configuration via a WebMvcConfigurer’s addCorsMappings applies CORS rules across all controllers in one place. The critical subtlety is that when Spring Security is on the classpath, CORS must also be registered with the security filter chain — otherwise the security filters can reject the preflight request before it ever reaches the MVC layer, and your carefully configured @CrossOrigin annotations appear to be ignored.

The reliable approach: a CorsConfigurationSource wired into HttpSecurity

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain chain(HttpSecurity http) throws Exception {
        http.cors(cors -> cors.configurationSource(corsSource()))
            .authorizeHttpRequests(a -> a.anyRequest().authenticated());
        return http.build();
    }

    @Bean
    CorsConfigurationSource corsSource() {
        CorsConfiguration cfg = new CorsConfiguration();
        cfg.setAllowedOrigins(List.of("https://javahandson.com"));
        cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
        cfg.setAllowedHeaders(List.of("Authorization", "Content-Type"));
        cfg.setAllowCredentials(true);
        UrlBasedCorsConfigurationSource src = new UrlBasedCorsConfigurationSource();
        src.registerCorsConfiguration("/**", cfg);
        return src;
    }
}
ApproachScopeWorks with Security?
@CrossOriginPer controller/methodNeeds http.cors() enabled too
addCorsMappings (MVC)All controllersNeeds http.cors() enabled too
CorsConfigurationSource + http.cors()Whole security chainYes — the reliable option
🎯 Interview Insight: The classic bug: ‘my @CrossOrigin works without Spring Security but stops working after I add it.’ The fix is calling http.cors() so the CorsFilter runs early in the security chain and handles the preflight OPTIONS request before authentication. When http.cors() is present with no explicit source, Spring will pick up a CorsConfigurationSource bean or the MVC CORS config automatically. State this and you show you understand the ordering of security filters, not just the annotations.

Q6. What is Session Management — stateless vs stateful, SessionCreationPolicy?

Answer – Session management determines whether and how Spring Security maintains server-side state between requests from the same user. A stateful application stores authentication in an HTTP session on the server; the browser holds only a session id cookie, and every request looks up the full security context from that session. A stateless application keeps no server session at all — each request must carry everything needed to authenticate itself, typically a JWT in the Authorization header. The choice shapes scalability, security posture, and the entire filter configuration.

SessionCreationPolicy is the enum that expresses your intent to Spring Security. It has four values. ALWAYS creates a session even if one is not strictly needed. IF_REQUIRED (the default) creates a session only when Spring Security actually needs one. NEVER means Spring Security will not create a session itself but will use one if it already exists. STATELESS means Spring Security will never create or use an HTTP session — the correct setting for a token-based REST API.

// STATELESS — the standard for JWT-secured REST APIs
http.sessionManagement(session -> session
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

// STATEFUL with concurrency control — one active session per user
http.sessionManagement(session -> session
        .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
        .maximumSessions(1)
        .maxSessionsPreventsLogin(true));
PolicyCreates session?Uses existing?
ALWAYSYes, alwaysYes
IF_REQUIRED (default)Only when neededYes
NEVERNoYes, if present
STATELESSNoNo — ignores it

When to Use Which

  • Use STATELESS for REST APIs authenticated by JWT or another self-contained token — it removes server session storage entirely and scales horizontally with no sticky sessions.
  • Use IF_REQUIRED for traditional server-rendered web applications with form login, where the session naturally holds the security context between page loads.
  • Use NEVER in the rare hybrid case where another component manages the session and you do not want Spring Security creating its own.
🎯 Interview Insight: Setting STATELESS alone is not enough to make an API truly stateless — you must also ensure the SecurityContext is populated fresh from the token on every request (via a JWT filter) rather than loaded from a session. A common mistake is setting STATELESS but leaving a mechanism that still writes to the session, so the app behaves inconsistently. STATELESS is a contract: nothing in the request pipeline should depend on server session state.

Q7. How do you implement JWT authentication in Spring Security — complete flow?

Answer – JWT (JSON Web Token) authentication replaces the server session with a signed, self-contained token. After a user proves their identity once (usually by posting credentials to a login endpoint), the server issues a JWT containing the user’s identity and authorities, signed with a secret or private key. The client stores this token and sends it in the Authorization header on every subsequent request. The server validates the signature and reads the claims — no session lookup, no database call on the hot path.

The complete flow has two phases. First, authentication: the user posts username and password, the AuthenticationManager verifies them, and on success the server generates a JWT and returns it. Second, authorization on every later request: a custom filter extracts the token from the header, validates it, builds an Authentication object from the claims, and places it in the SecurityContextHolder so the rest of the chain treats the request as authenticated.

Step 1 — The login endpoint issues the token

@RestController
@RequestMapping("/auth")
public class AuthController {
    private final AuthenticationManager authManager;
    private final JwtService jwtService;

    @PostMapping("/login")
    public TokenResponse login(@RequestBody LoginRequest req) {
        Authentication auth = authManager.authenticate(
            new UsernamePasswordAuthenticationToken(req.username(), req.password()));
        String token = jwtService.generateToken(auth);
        return new TokenResponse(token);
    }
}

Step 2 — Generate and sign the token

@Service
public class JwtService {
    private final SecretKey key = Keys.hmacShaKeyFor(SECRET.getBytes());

    public String generateToken(Authentication auth) {
        String roles = auth.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));
        return Jwts.builder()
                .setSubject(auth.getName())
                .claim("roles", roles)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + 900_000)) // 15 min
                .signWith(key)
                .compact();
    }
}

Step 3 — Wire the JWT filter into the chain

@Bean
SecurityFilterChain chain(HttpSecurity http, JwtAuthFilter jwtFilter) throws Exception {
    http
        .csrf(csrf -> csrf.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(a -> a
            .requestMatchers("/auth/**").permitAll()
            .anyRequest().authenticated())
        // run the JWT filter before the username/password filter
        .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
    return http.build();
}
🎯 Interview Insight: A strong candidate volunteers the trade-off nobody asked about: JWTs cannot be revoked before they expire, because validation is purely cryptographic and involves no server lookup. If a token is stolen, it stays valid until expiry. That is exactly why access tokens are kept short-lived (minutes) and paired with a refresh token — the topic of Q10. Naming this limitation unprompted shows you understand JWT security beyond the happy path.

Q8. What is OncePerRequestFilter — why use it for JWT validation?

Answer – OncePerRequestFilter is a Spring base class for servlet filters that guarantees the filter’s logic runs exactly once per request, no matter how many times the request is dispatched internally. Servlet containers can dispatch a single request multiple times — for a forward, an include, or an async re-dispatch — and a plain Filter would run again on each dispatch. For JWT validation, running twice is at best wasted work and at worst a source of subtle bugs, so extending OncePerRequestFilter is the idiomatic choice.

The class works by storing a request attribute the first time it runs and checking for that attribute on subsequent dispatches, short-circuiting if it is already present. You implement a single method, doFilterInternal, and Spring handles the once-per-request bookkeeping for you. This is why virtually every JWT tutorial and production codebase builds the token filter on top of OncePerRequestFilter rather than the raw Filter interface.

@Component
public class JwtAuthFilter extends OncePerRequestFilter {
    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws ServletException, IOException {

        String header = request.getHeader("Authorization");
        if (header == null || !header.startsWith("Bearer ")) {
            chain.doFilter(request, response);   // no token — let it continue
            return;
        }
        String token = header.substring(7);
        // ... validate token and set SecurityContext (see Q9) ...
        chain.doFilter(request, response);
    }
}
🎯 Interview Insight: The revealing detail is the early return when there is no token: the filter calls chain.doFilter and returns without setting anything. It must not reject the request itself — authorization is decided later by the authorization filter based on whether the endpoint is public or protected. A JWT filter that returns 401 whenever a token is missing breaks all your permitAll() endpoints. Its job is only to authenticate when a valid token is present, never to authorize.

Q9. How do you validate and parse JWT tokens inside the filter?

Answer – Inside the filter, validating a JWT means three things: verifying the cryptographic signature with the same key used to sign it, confirming the token has not expired, and extracting the claims to rebuild an Authentication object. If any check fails, the filter simply does not set an authentication, leaving the request anonymous so downstream authorization can reject it. If all checks pass, the filter constructs an authenticated token and stores it in the SecurityContextHolder for the remainder of the request.

The parsing library (here, JJWT) throws on a bad signature or an expired token, so validation and extraction happen together inside a try/catch. On success you read the subject and the roles claim, convert the roles into GrantedAuthority objects, and build a UsernamePasswordAuthenticationToken marked as authenticated. Setting the authentication in the context is what makes the rest of Spring Security — including @PreAuthorize checks and URL rules — treat the caller as logged in.

@Override
protected void doFilterInternal(HttpServletRequest request,
                                HttpServletResponse response,
                                FilterChain chain) throws ServletException, IOException {
    String header = request.getHeader("Authorization");
    if (header != null && header.startsWith("Bearer ")) {
        String token = header.substring(7);
        try {
            Claims claims = Jwts.parserBuilder()
                    .setSigningKey(key)          // verifies signature
                    .build()
                    .parseClaimsJws(token)       // throws if expired or tampered
                    .getBody();

            String username = claims.getSubject();
            List<SimpleGrantedAuthority> authorities =
                Arrays.stream(claims.get("roles", String.class).split(","))
                      .map(SimpleGrantedAuthority::new)
                      .toList();

            var authToken = new UsernamePasswordAuthenticationToken(
                    username, null, authorities);
            SecurityContextHolder.getContext().setAuthentication(authToken);
        } catch (JwtException ex) {
            // invalid/expired token — leave context empty, request stays anonymous
            SecurityContextHolder.clearContext();
        }
    }
    chain.doFilter(request, response);
}

Key points:

  • The signing key must be identical to the one used at token generation for the signature check to succeed.
  • parseClaimsJws throws ExpiredJwtException, SignatureException, or MalformedJwtException — all subclasses of JwtException — so a single catch handles every invalid-token case.
  • On failure, clear the context rather than throwing, so public endpoints still work and protected ones are cleanly rejected by the authorization filter.
🎯 Interview Insight: A subtle production concern is clock skew. If your auth server and resource server have slightly different clocks, a token can appear expired a few seconds early or late. JJWT lets you set an allowed clock skew (setAllowedClockSkewSeconds) so brief drift does not cause spurious authentication failures. Mentioning clock skew signals you have run JWT validation across multiple machines, not just on localhost.

Q10. What is the Refresh Token pattern — how do you implement it securely?

Answer – The refresh token pattern solves the tension between security and usability that short-lived access tokens create. An access token is deliberately short-lived — often 15 minutes — so that a stolen token is only useful briefly. But forcing users to log in with their password every 15 minutes is unacceptable. The solution is a second, longer-lived token: the refresh token. When the access token expires, the client presents the refresh token to a dedicated endpoint and receives a fresh access token without re-entering credentials.

The two tokens have very different security properties. The access token is sent on every request and validated statelessly, so it must be short-lived. The refresh token is sent only to the refresh endpoint, so it is exposed far less often and can live for days or weeks. Crucially, refresh tokens are usually stored server-side (in a database or cache) so they can be revoked — which is what makes it possible to log a user out or invalidate a stolen session, something a pure access token cannot offer.

@PostMapping("/auth/refresh")
public TokenResponse refresh(@RequestBody RefreshRequest req) {
    // 1. Look up the refresh token server-side
    RefreshToken stored = refreshTokenRepo.findByToken(req.refreshToken())
            .orElseThrow(() -> new BadCredentialsException("Invalid refresh token"));

    // 2. Reject if expired or revoked
    if (stored.isExpired() || stored.isRevoked()) {
        refreshTokenRepo.delete(stored);
        throw new BadCredentialsException("Refresh token expired");
    }

    // 3. Issue a NEW access token
    String newAccess = jwtService.generateToken(stored.getUser());

    // 4. Rotate: invalidate the old refresh token and issue a new one
    String newRefresh = refreshTokenService.rotate(stored);

    return new TokenResponse(newAccess, newRefresh);
}

Security best practices

  • Rotate refresh tokens on every use — issue a new one and invalidate the old, so a stolen token becomes useless after the legitimate client next refreshes.
  • Detect reuse: if a already-rotated (used) refresh token is presented again, treat it as theft and revoke the entire token family for that user.
  • Store refresh tokens hashed, not in plaintext, so a database leak does not immediately expose usable tokens.
  • Deliver refresh tokens to browser clients in an HttpOnly, Secure, SameSite cookie so JavaScript — and therefore XSS — cannot read them.
🎯 Interview Insight: The advanced follow-up is refresh token rotation with reuse detection. Each refresh issues a new refresh token and marks the old one used. If an old, already-used token ever reappears, it means either the client or an attacker replayed it — so you revoke the whole chain and force re-login. This turns the refresh endpoint into an intrusion detector, and describing it demonstrates security thinking well past the average candidate.

Q11. What is OAuth2 — explain the Authorization Code flow end to end?

Answer – OAuth2 is an authorization framework that lets a user grant a third-party application limited access to their resources without sharing their password. Instead of giving your credentials to every app, you authenticate once with a trusted authorization server (Google, GitHub, an enterprise identity provider), which issues the application a token scoped to specific permissions. The application never sees your password — it only receives a token it can use on your behalf.

The Authorization Code flow is the most secure and most common OAuth2 grant type for web applications. It involves four parties: the resource owner (the user), the client (your application), the authorization server (which authenticates the user and issues tokens), and the resource server (which holds the protected data). The distinguishing feature of this flow is that the client first receives a short-lived authorization code through the browser, then exchanges that code for tokens through a secure back-channel call — so the tokens themselves never travel through the browser where they could be intercepted.

The flow, step by step

  • The user clicks ‘Log in with Google’; the client redirects the browser to the authorization server with its client_id, requested scopes, and a redirect_uri.
  • The authorization server authenticates the user and asks them to consent to the requested scopes.
  • On consent, the authorization server redirects the browser back to the client’s redirect_uri with a short-lived authorization code in the query string.
  • The client’s back end exchanges that code — plus its client_secret — for an access token (and often a refresh token and ID token) via a direct server-to-server POST.
  • The client uses the access token to call the resource server, which validates the token and returns the protected data.
// Spring Security as an OAuth2 client — the flow is handled for you
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(a -> a.anyRequest().authenticated())
        .oauth2Login(Customizer.withDefaults());   // triggers the Authorization Code flow
    return http.build();
}

// Register the provider explicitly as a Java-configured bean
@Bean
ClientRegistrationRepository clientRegistrations() {
    ClientRegistration google = ClientRegistration.withRegistrationId("google")
        .clientId("your-client-id")
        .clientSecret("your-client-secret")
        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
        .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
        .scope("openid", "profile", "email")
        .authorizationUri("https://accounts.google.com/o/oauth2/v2/auth")
        .tokenUri("https://oauth2.googleapis.com/token")
        .userInfoUri("https://openidconnect.googleapis.com/v1/userinfo")
        .userNameAttributeName("sub")
        .build();
    return new InMemoryClientRegistrationRepository(google);
}
🎯 Interview Insight: Two follow-ups separate strong candidates. First: why the code exchange step at all, rather than returning the token directly? Because the browser is an untrusted channel — the code is useless without the client_secret, which never leaves the server. Second: PKCE (Proof Key for Code Exchange) extends this flow for public clients like SPAs and mobile apps that cannot hold a secret, using a per-request code_verifier/code_challenge pair instead. Mentioning PKCE shows you know the modern, recommended variant.

Advanced Level — Q12 to Q15

Q12. How do you configure a Resource Server in Spring Security for JWT validation?

Answer – A resource server is an application that hosts protected APIs and accepts bearer tokens issued by an authorization server. Rather than handling login itself, it simply validates the JWT that arrives on each request and grants or denies access based on the token’s claims. Spring Security’s OAuth2 Resource Server support makes this almost configuration-only: you point it at the authorization server’s public keys and it validates every incoming token automatically, with no custom filter required.

Validation can work two ways. For JWTs signed with an asymmetric key, the resource server downloads the authorization server’s public keys from a JWKS (JSON Web Key Set) endpoint and verifies signatures locally — fast, and requiring no call to the auth server per request. The issuer-uri property lets Spring discover the JWKS endpoint automatically through the OpenID Connect discovery document. This is the recommended production setup because it is both secure and performant.

// Enable JWT resource-server validation in the filter chain
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(a -> a
            .requestMatchers("/public/**").permitAll()
            .anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
    return http.build();
}

// Java-configured decoder — discovers the JWKS endpoint from the issuer
@Bean
JwtDecoder jwtDecoder() {
    return JwtDecoders.fromIssuerLocation("https://auth.example.com/realms/app");
}

Mapping token scopes/claims to authorities

// Convert a custom 'roles' claim into Spring authorities
@Bean
JwtAuthenticationConverter jwtAuthConverter() {
    JwtGrantedAuthoritiesConverter authorities = new JwtGrantedAuthoritiesConverter();
    authorities.setAuthoritiesClaimName("roles");
    authorities.setAuthorityPrefix("ROLE_");
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(authorities);
    return converter;
}
🎯 Interview Insight: The distinction interviewers probe here is local JWT validation versus token introspection. Local validation with JWKS needs no per-request call to the auth server, so it is fast but cannot know if a token was revoked early. Introspection (opaque tokens) calls the auth server on every request, giving instant revocation at the cost of latency and a hard dependency on the auth server’s availability. Choosing between them is a real architectural trade-off — say which you would pick and why.

Q13. What is a custom AuthenticationEntryPoint?

Answer – An AuthenticationEntryPoint decides what happens when an unauthenticated user tries to access a protected resource. It is the component Spring Security invokes at the start of authentication — the entry point into the login process. For a traditional web application the default entry point redirects the browser to a login page. For a REST API that behaviour is wrong: an API client wants a 401 status and a JSON error body, not an HTML redirect. A custom AuthenticationEntryPoint gives you that control.

The interface has a single method, commence, invoked by the ExceptionTranslationFilter whenever an AuthenticationException bubbles up — for example when a request with no valid token hits a secured endpoint. In your implementation you write the appropriate status code and response body directly. For a JWT API this is where you return 401 Unauthorized with a machine-readable JSON payload the client can parse.

@Component
public class RestAuthEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException)
            throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
        response.setContentType("application/json");
        response.getWriter().write("""
            {"error": "Unauthorized", "message": "Authentication required"}
            """);
    }
}

// Register it in the security chain
http.exceptionHandling(ex -> ex.authenticationEntryPoint(restAuthEntryPoint));
🎯 Interview Insight: The precise distinction to draw is entry point versus access-denied handler. AuthenticationEntryPoint fires when the user is not authenticated at all (401 — ‘who are you?’). AccessDeniedHandler fires when the user IS authenticated but lacks permission (403 — ‘I know you, but you can’t do that’). Confusing the two is common; naming both and their status codes shows you understand where each sits in the ExceptionTranslationFilter’s logic. The access-denied handler is the subject of the next question.

Q14. What is AccessDeniedHandler — how do you customise it?

Answer – AccessDeniedHandler decides what happens when an authenticated user is refused access to a resource they are not authorized to use. Where AuthenticationEntryPoint handles the ‘not logged in’ case with a 401, AccessDeniedHandler handles the ‘logged in but not allowed’ case with a 403 Forbidden. The default handler renders an error page, which — like the default entry point — is inappropriate for a JSON API that needs a structured error response.

It, too, is a single-method interface: handle is called by the ExceptionTranslationFilter when an AccessDeniedException is thrown, typically because a URL rule or a @PreAuthorize check rejected an authenticated request. Your custom implementation writes a 403 status and a JSON body describing the failure, so the client can distinguish ‘you need to log in’ from ‘you are logged in but forbidden’ and react accordingly.

@Component
public class RestAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException ex) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403
        response.setContentType("application/json");
        response.getWriter().write("""
            {"error": "Forbidden", "message": "Insufficient privileges"}
            """);
    }
}

// Register both handlers together
http.exceptionHandling(ex -> ex
        .authenticationEntryPoint(restAuthEntryPoint)   // 401
        .accessDeniedHandler(restAccessDeniedHandler)); // 403
ComponentTriggered whenHTTP status
AuthenticationEntryPointUser not authenticated401 Unauthorized
AccessDeniedHandlerAuthenticated but not allowed403 Forbidden
🎯 Interview Insight: One nuance interviewers like: for method-level @PreAuthorize failures, the AccessDeniedException is thrown deeper in the stack, and whether your AccessDeniedHandler catches it depends on the exception propagating back out to the ExceptionTranslationFilter. In some setups method-security denials are handled by a different mechanism, so testing that your 403 JSON body appears for both URL-level and method-level denials is worth calling out as a real-world verification step.

Q15. What are the major changes in Spring Security 6.x from 5.x?

Answer – Spring Security 6.x, which aligns with Spring Framework 6, is a significant modernization that removes long-deprecated APIs and standardizes on the lambda DSL. If an interviewer asks this, they want to confirm you can work in a current codebase rather than copying 5.x-era configuration. The changes are less about new features and more about a cleaner, more consistent configuration model and a baseline platform bump.

The headline change is the removal of WebSecurityConfigurerAdapter. In 5.x you extended this class and overrode configure methods; in 6.x you instead declare a SecurityFilterChain bean and configure everything through it. Alongside that, the authorization DSL changed: authorizeRequests and antMatchers are gone, replaced by authorizeHttpRequests and requestMatchers. The whole configuration API now uses lambdas consistently rather than the older and() chaining style.

Key changes at a glance

AreaSpring Security 5.xSpring Security 6.x
Config baseextend WebSecurityConfigurerAdapterSecurityFilterChain bean
Authorization DSLauthorizeRequests()authorizeHttpRequests()
MatchersantMatchers() / mvcMatchers()requestMatchers()
Method security@EnableGlobalMethodSecurity@EnableMethodSecurity
Java baselineJava 8+Java 17+
Config styleand() chaininglambda DSL only

Before (5.x) and after (6.x)

// 5.x — extend the adapter, override configure
@Configuration
public class OldConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated();
    }
}

// 6.x — declare a bean, use lambdas and requestMatchers
@Configuration
@EnableWebSecurity
public class NewConfig {
    @Bean
    SecurityFilterChain chain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(a -> a
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated());
        return http.build();
    }
}
🎯 Interview Insight: The change that catches people migrating is the requestMatchers path-matching behaviour. In 6.x the matching is stricter and the old ant/mvc matcher distinction is unified, so a pattern that silently matched in 5.x may now behave differently — particularly around trailing slashes, which 6.x no longer matches by default. Being able to say ‘the WebSecurityConfigurerAdapter removal gets the headlines, but the matcher semantics are what actually break migrations’ shows you have done a real 5.x-to-6.x upgrade.

Conclusion

Authorization, JWT, and OAuth2 are where Spring Security stops being a checkbox and starts requiring real understanding. Knowing that roles are just prefixed authorities, that @PreAuthorize runs before the method while @PostAuthorize runs after, and that a stateless JWT API needs CSRF disabled but a session app does not — these are the distinctions that separate developers who can configure security from those who can reason about it under production conditions.

Key takeaways from this article:

Authorization in Spring Security is built on GrantedAuthority; roles are simply authorities with a ROLE_ prefix, and the method annotations (@PreAuthorize, @PostAuthorize, @Secured, @RolesAllowed) differ in timing and SpEL support.

JWT authentication replaces the server session with a signed, self-contained token validated statelessly in a OncePerRequestFilter. Access tokens are short-lived and paired with revocable refresh tokens to balance security and usability.

OAuth2’s Authorization Code flow lets a trusted authorization server issue tokens without your app ever seeing the user’s password, and Spring’s resource-server support validates those tokens with near-zero configuration via JWKS discovery.

AuthenticationEntryPoint (401) and AccessDeniedHandler (403) let you return proper JSON errors for APIs instead of HTML redirects, and Spring Security 6.x modernizes all of this around the SecurityFilterChain bean and the lambda DSL.

Further Reading

 

Leave a Comment