← All projects

Auth Service with JWT and RBAC

Build a stateless authentication service on Spring Security 6 with BCrypt passwords, short-lived JWT access tokens, rotating refresh tokens and role-based authorization.

APIs & ServicesIntermediate8-10 hours#security#jwt#rest#persistenceSpring BootSpring Security 6JPAPostgreSQL

Revise first: Spring Security & JWTJava SecurityREST API Design, Validation & ControllerAdviceJava Testing (JUnit, Mockito)

You will build register, login, refresh and logout endpoints, plus protected resources guarded by roles. Access tokens are short-lived JWTs verified without a database call; refresh tokens are opaque, stored hashed, and rotated on every use. Interviewers care because authentication is where small mistakes (weak hashing, no expiry, accepting alg=none, storing raw refresh tokens) become incidents, and Spring Security 6 removed WebSecurityConfigurerAdapter, so candidates must know the component-based configuration.

Project layout

src/main/java/com/example/auth/
  config/     SecurityConfig, JwtProperties
  api/        AuthController, LoginRequest, TokenResponse
  domain/     User, Role, RefreshToken and their repositories
  security/   JwtService, JwtAuthFilter
  service/    AuthService

Passwords and the security filter chain

BCrypt embeds a per-hash salt and a cost factor; never write your own hashing. Disable CSRF only because the API is stateless and uses no cookies for auth.

@Configuration
@EnableMethodSecurity
public class SecurityConfig {
    @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }

    @Bean
    SecurityFilterChain chain(HttpSecurity http, JwtAuthFilter jwt) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(a -> a
                .requestMatchers("/auth/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .addFilterBefore(jwt, UsernamePasswordAuthenticationFilter.class)
            .build();
    }
}

Issuing and verifying access tokens

Use the jjwt library (0.12.x API). Keep the signing key in configuration, at least 256 bits for HS256, and put only what you need in claims.

public String accessToken(User u) {
    Instant now = Instant.now();
    return Jwts.builder()
        .subject(u.getId().toString())
        .claim("roles", u.getRoles().stream().map(Role::getName).toList())
        .issuedAt(Date.from(now))
        .expiration(Date.from(now.plus(Duration.ofMinutes(15))))
        .signWith(key)                       // SecretKey from Keys.hmacShaKeyFor(bytes)
        .compact();
}

public Claims parse(String token) {
    return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload();
}

parseSignedClaims rejects unsigned tokens and checks exp, which is why you use it rather than a lenient parse. The filter extracts the bearer token, parses it, and populates the SecurityContext.

@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
        throws ServletException, IOException {
    String h = req.getHeader(HttpHeaders.AUTHORIZATION);
    if (h != null && h.startsWith("Bearer ")) {
        try {
            Claims c = jwt.parse(h.substring(7));
            var auths = ((List<?>) c.get("roles")).stream()
                .map(r -> new SimpleGrantedAuthority("ROLE_" + r)).toList();
            SecurityContextHolder.getContext()
                .setAuthentication(new UsernamePasswordAuthenticationToken(c.getSubject(), null, auths));
        } catch (JwtException | IllegalArgumentException e) {
            SecurityContextHolder.clearContext();   // falls through to 401
        }
    }
    chain.doFilter(req, res);
}

Refresh token rotation

A refresh token is a random 256-bit value. Store only its SHA-256 hash so a database leak does not yield usable tokens. On refresh, mark the presented token used and issue a new pair. If a used token is presented again, assume theft and revoke the whole family.

@Transactional
public TokenResponse refresh(String raw) {
    RefreshToken t = tokens.findByHash(sha256(raw)).orElseThrow(BadCredentialsException::new);
    if (t.isExpired()) throw new BadCredentialsException("expired");
    if (t.isUsed()) {                         // reuse detected
        tokens.revokeFamily(t.getFamilyId());
        throw new BadCredentialsException("reuse");
    }
    t.markUsed();
    return issue(t.getUser(), t.getFamilyId());
}

issue creates a new RefreshToken row with the same familyId, a new random value, and a 7-day expiry. Return the raw value to the client once.

Authorization and testing

Role checks go in the filter chain for URL groups and in @PreAuthorize for finer rules such as ownership.

@GetMapping("/users/{id}")
@PreAuthorize("hasRole('ADMIN') or #id.toString() == authentication.name")
public UserDto get(@PathVariable UUID id) { return users.find(id); }
@SpringBootTest
@AutoConfigureMockMvc
class AuthIT {
    @Autowired MockMvc mvc;

    @Test
    void adminEndpointRejectsUserToken() throws Exception {
        String token = tokenFor("alice", "USER");
        mvc.perform(get("/admin/stats").header("Authorization", "Bearer " + token))
           .andExpect(status().isForbidden());
    }

    @Test
    void noTokenIs401() throws Exception {
        mvc.perform(get("/users/1")).andExpect(status().isUnauthorized());
    }
}

Interview angle

  • Why short access tokens plus refresh tokens? A JWT cannot be revoked before exp without a lookup, so keep exp short (minutes) to bound damage, and make revocation possible through the stateful refresh token.
  • 401 versus 403? 401 means unauthenticated (no or invalid credentials); 403 means authenticated but not permitted. Configure an AuthenticationEntryPoint to return 401 explicitly.
  • Where do you store tokens in a browser? An httpOnly, Secure, SameSite cookie for the refresh token resists XSS theft; if you use cookies for auth you must reconsider CSRF. Memory for the access token.
  • Why BCrypt and not SHA-256? Fast hashes are brute-forced at billions per second; BCrypt is deliberately slow, salted and has a tunable cost. Argon2 is the modern alternative.

Milestones

0 of 7 milestones

Done when

Stretch goals