Back to blog

Build a Video Platform: Authentication

javaspring-bootreactnextjsvideo-streaming
Build a Video Platform: Authentication

Our platform runs, but anyone can access any endpoint. Time to lock it down. In this post, we'll build a complete authentication system — email/password registration, JWT access tokens, refresh token rotation, Google and GitHub OAuth2 login, and a Next.js frontend that handles the entire auth flow.

This is one of the longer posts in the series because authentication touches every layer: database schema, Spring Security configuration, REST endpoints, cookie handling, and frontend state management. Get this right, and every future post just builds on top.

Time commitment: 2–3 hours
Prerequisites: Phase 1: Project Setup

What we'll build in this post:
✅ User and OAuth account database schema
✅ Spring Security configuration with JWT filter
✅ Email/password registration and login endpoints
✅ Access token (15 min) + refresh token (7 days) with rotation
✅ Google and GitHub OAuth2 social login
✅ Next.js auth context with automatic token refresh
✅ Protected routes and role-based access


Authentication Architecture

Before writing code, let's understand the full auth flow:

Token Strategy

TokenStorageLifetimePurpose
Access tokenJavaScript memory (variable)15 minutesAuthenticate API requests
Refresh tokenHTTP-only secure cookie7 daysObtain new access tokens

Why this split?

  • Access tokens in memory can't be stolen by XSS attacks (no localStorage/sessionStorage)
  • Refresh tokens in HTTP-only cookies can't be read by JavaScript at all
  • Short access token lifetime limits damage if somehow leaked
  • Refresh token rotation: each refresh issues a new refresh token and invalidates the old one

Database Schema

Add Dependencies

Add the security and JWT dependencies to backend/build.gradle:

dependencies {
    // ... existing dependencies
 
    // Security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
 
    // JWT
    implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
 
    // Test
    testImplementation 'org.springframework.security:spring-security-test'
}

User Entity

backend/src/main/java/dev/chanhle/courses/auth/entity/User.java:

package dev.chanhle.courses.auth.entity;
 
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
 
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
 
@Entity
@Table(name = "users")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(unique = true, nullable = false)
    private String email;
 
    private String passwordHash;
 
    @Column(nullable = false)
    private String name;
 
    private String avatarUrl;
 
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    @Builder.Default
    private Role role = Role.USER;
 
    @Builder.Default
    private boolean emailVerified = false;
 
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    @Builder.Default
    private List<OAuthAccount> oauthAccounts = new ArrayList<>();
 
    @CreationTimestamp
    private Instant createdAt;
 
    @UpdateTimestamp
    private Instant updatedAt;
 
    public enum Role {
        USER, ADMIN
    }
}

OAuth Account Entity

backend/src/main/java/dev/chanhle/courses/auth/entity/OAuthAccount.java:

package dev.chanhle.courses.auth.entity;
 
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
 
import java.time.Instant;
 
@Entity
@Table(name = "oauth_accounts",
       uniqueConstraints = @UniqueConstraint(
           columns = {"provider", "providerAccountId"}
       ))
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OAuthAccount {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;
 
    @Column(nullable = false)
    private String provider;
 
    @Column(nullable = false)
    private String providerAccountId;
 
    private String accessToken;
 
    private String refreshToken;
 
    @CreationTimestamp
    private Instant createdAt;
}

User Repository

backend/src/main/java/dev/chanhle/courses/auth/repository/UserRepository.java:

package dev.chanhle.courses.auth.repository;
 
import dev.chanhle.courses.auth.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
 
import java.util.Optional;
 
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
}

OAuth Account Repository

backend/src/main/java/dev/chanhle/courses/auth/repository/OAuthAccountRepository.java:

package dev.chanhle.courses.auth.repository;
 
import dev.chanhle.courses.auth.entity.OAuthAccount;
import org.springframework.data.jpa.repository.JpaRepository;
 
import java.util.Optional;
 
public interface OAuthAccountRepository extends JpaRepository<OAuthAccount, Long> {
    Optional<OAuthAccount> findByProviderAndProviderAccountId(
            String provider, String providerAccountId);
}

JWT Service

The JWT service handles token creation and validation. It uses the jjwt library for signing tokens with HMAC-SHA256.

backend/src/main/java/dev/chanhle/courses/auth/service/JwtService.java:

package dev.chanhle.courses.auth.service;
 
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
 
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
 
@Service
public class JwtService {
 
    private final SecretKey signingKey;
    private final Duration accessTokenExpiry;
 
    public JwtService(
            @Value("${app.jwt.secret}") String secret,
            @Value("${app.jwt.access-token-expiry:15m}") Duration accessTokenExpiry) {
        this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.accessTokenExpiry = accessTokenExpiry;
    }
 
    public String generateAccessToken(Long userId, String email, String role) {
        Instant now = Instant.now();
        return Jwts.builder()
                .subject(userId.toString())
                .claim("email", email)
                .claim("role", role)
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plus(accessTokenExpiry)))
                .signWith(signingKey)
                .compact();
    }
 
    public Claims parseToken(String token) {
        return Jwts.parser()
                .verifyWith(signingKey)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
 
    public boolean isTokenValid(String token) {
        try {
            parseToken(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }
 
    public Long getUserId(String token) {
        return Long.parseLong(parseToken(token).getSubject());
    }
 
    public String getEmail(String token) {
        return parseToken(token).get("email", String.class);
    }
 
    public String getRole(String token) {
        return parseToken(token).get("role", String.class);
    }
}

Add the JWT configuration to application.yml:

app:
  jwt:
    secret: ${JWT_SECRET:your-256-bit-secret-key-change-this-in-production-min-32-chars}
    access-token-expiry: 15m
    refresh-token-expiry: 7d

Important: The JWT secret must be at least 32 characters for HMAC-SHA256. In production, use a random string stored as an environment variable.


Refresh Token Service

Refresh tokens are stored in Redis for fast lookup and easy invalidation. Each refresh is a UUID — no JWT parsing needed.

backend/src/main/java/dev/chanhle/courses/auth/service/RefreshTokenService.java:

package dev.chanhle.courses.auth.service;
 
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
 
import java.time.Duration;
import java.util.UUID;
 
@Service
public class RefreshTokenService {
 
    private static final String PREFIX = "refresh_token:";
 
    private final StringRedisTemplate redisTemplate;
    private final Duration refreshTokenExpiry;
 
    public RefreshTokenService(
            StringRedisTemplate redisTemplate,
            @Value("${app.jwt.refresh-token-expiry:7d}") Duration refreshTokenExpiry) {
        this.redisTemplate = redisTemplate;
        this.refreshTokenExpiry = refreshTokenExpiry;
    }
 
    public String createRefreshToken(Long userId) {
        String token = UUID.randomUUID().toString();
        redisTemplate.opsForValue().set(
                PREFIX + token,
                userId.toString(),
                refreshTokenExpiry
        );
        return token;
    }
 
    public Long validateRefreshToken(String token) {
        String userId = redisTemplate.opsForValue().get(PREFIX + token);
        if (userId == null) {
            return null;
        }
        return Long.parseLong(userId);
    }
 
    public void revokeRefreshToken(String token) {
        redisTemplate.delete(PREFIX + token);
    }
 
    public String rotateRefreshToken(String oldToken) {
        Long userId = validateRefreshToken(oldToken);
        if (userId == null) {
            return null;
        }
        revokeRefreshToken(oldToken);
        return createRefreshToken(userId);
    }
}

Refresh token rotation: When a client uses a refresh token to get a new access token, we:

  1. Validate the old refresh token
  2. Delete it from Redis (one-time use)
  3. Create a new refresh token
  4. Return both new tokens

This limits the damage if a refresh token is stolen — the attacker can only use it once before it's invalidated.


Spring Security Configuration

JWT Authentication Filter

This filter runs on every request, extracts the JWT from the Authorization header, validates it, and sets the authenticated user in the security context.

backend/src/main/java/dev/chanhle/courses/config/JwtAuthenticationFilter.java:

package dev.chanhle.courses.config;
 
import dev.chanhle.courses.auth.service.JwtService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
 
import java.io.IOException;
import java.util.List;
 
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
 
    private final JwtService jwtService;
 
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
 
        String authHeader = request.getHeader("Authorization");
 
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
 
        String token = authHeader.substring(7);
 
        if (jwtService.isTokenValid(token)) {
            Long userId = jwtService.getUserId(token);
            String role = jwtService.getRole(token);
 
            UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                            userId,
                            null,
                            List.of(new SimpleGrantedAuthority("ROLE_" + role))
                    );
 
            SecurityContextHolder.getContext().setAuthentication(authToken);
        }
 
        filterChain.doFilter(request, response);
    }
 
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String path = request.getServletPath();
        return path.startsWith("/api/auth/") || path.equals("/api/health");
    }
}

Key design decisions:

  • shouldNotFilter — auth endpoints and health check bypass JWT validation (they're public)
  • principal = userId — we store the user ID as the principal, making it easy to get in controllers: (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal()
  • Role prefix — Spring Security expects roles prefixed with ROLE_ for hasRole() checks

Security Configuration

backend/src/main/java/dev/chanhle/courses/config/SecurityConfig.java:

package dev.chanhle.courses.config;
 
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
 
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
 
    private final JwtAuthenticationFilter jwtAuthFilter;
 
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                // Public endpoints
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/health").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/courses/**").permitAll()
 
                // Admin endpoints
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
 
                // Everything else requires authentication
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
 
        return http.build();
    }
 
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Key decisions:

  • CSRF disabled — we use JWT tokens, not session cookies, so CSRF protection isn't needed
  • Stateless sessions — no server-side session; every request carries its own JWT
  • Public endpoints — auth, health, and course listing (GET) are accessible without login
  • Admin gating/api/admin/** requires the ADMIN role
  • Filter ordering — JWT filter runs before Spring's default username/password filter

Auth DTOs

Request and response objects for the auth endpoints:

backend/src/main/java/dev/chanhle/courses/auth/dto/RegisterRequest.java:

package dev.chanhle.courses.auth.dto;
 
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
 
@Data
public class RegisterRequest {
    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100)
    private String name;
 
    @NotBlank(message = "Email is required")
    @Email(message = "Invalid email format")
    private String email;
 
    @NotBlank(message = "Password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    private String password;
}

backend/src/main/java/dev/chanhle/courses/auth/dto/LoginRequest.java:

package dev.chanhle.courses.auth.dto;
 
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
 
@Data
public class LoginRequest {
    @NotBlank(message = "Email is required")
    @Email(message = "Invalid email format")
    private String email;
 
    @NotBlank(message = "Password is required")
    private String password;
}

backend/src/main/java/dev/chanhle/courses/auth/dto/AuthResponse.java:

package dev.chanhle.courses.auth.dto;
 
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
 
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthResponse {
    private String accessToken;
    private UserDto user;
 
    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static class UserDto {
        private Long id;
        private String email;
        private String name;
        private String avatarUrl;
        private String role;
    }
}

Notice: the refresh token is NOT in the response body. It's set as an HTTP-only cookie — the frontend JavaScript never sees it.


Auth Service

The service layer handles registration, login, and token management:

backend/src/main/java/dev/chanhle/courses/auth/service/AuthService.java:

package dev.chanhle.courses.auth.service;
 
import dev.chanhle.courses.auth.dto.AuthResponse;
import dev.chanhle.courses.auth.dto.LoginRequest;
import dev.chanhle.courses.auth.dto.RegisterRequest;
import dev.chanhle.courses.auth.entity.User;
import dev.chanhle.courses.auth.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
@Service
@RequiredArgsConstructor
public class AuthService {
 
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    private final RefreshTokenService refreshTokenService;
 
    @Transactional
    public AuthResult register(RegisterRequest request) {
        if (userRepository.existsByEmail(request.getEmail())) {
            throw new IllegalArgumentException("Email already registered");
        }
 
        User user = User.builder()
                .email(request.getEmail())
                .passwordHash(passwordEncoder.encode(request.getPassword()))
                .name(request.getName())
                .role(User.Role.USER)
                .emailVerified(false)
                .build();
 
        user = userRepository.save(user);
 
        return generateAuthResult(user);
    }
 
    public AuthResult login(LoginRequest request) {
        User user = userRepository.findByEmail(request.getEmail())
                .orElseThrow(() -> new IllegalArgumentException("Invalid email or password"));
 
        if (user.getPasswordHash() == null) {
            throw new IllegalArgumentException(
                    "This account uses social login. Please sign in with Google or GitHub.");
        }
 
        if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
            throw new IllegalArgumentException("Invalid email or password");
        }
 
        return generateAuthResult(user);
    }
 
    public AuthResult refresh(String refreshToken) {
        Long userId = refreshTokenService.validateRefreshToken(refreshToken);
        if (userId == null) {
            throw new IllegalArgumentException("Invalid or expired refresh token");
        }
 
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("User not found"));
 
        // Rotate: revoke old, create new
        String newRefreshToken = refreshTokenService.rotateRefreshToken(refreshToken);
 
        String accessToken = jwtService.generateAccessToken(
                user.getId(), user.getEmail(), user.getRole().name());
 
        AuthResponse response = buildAuthResponse(user, accessToken);
        return new AuthResult(response, newRefreshToken);
    }
 
    public void logout(String refreshToken) {
        if (refreshToken != null) {
            refreshTokenService.revokeRefreshToken(refreshToken);
        }
    }
 
    private AuthResult generateAuthResult(User user) {
        String accessToken = jwtService.generateAccessToken(
                user.getId(), user.getEmail(), user.getRole().name());
        String refreshToken = refreshTokenService.createRefreshToken(user.getId());
 
        AuthResponse response = buildAuthResponse(user, accessToken);
        return new AuthResult(response, refreshToken);
    }
 
    private AuthResponse buildAuthResponse(User user, String accessToken) {
        return AuthResponse.builder()
                .accessToken(accessToken)
                .user(AuthResponse.UserDto.builder()
                        .id(user.getId())
                        .email(user.getEmail())
                        .name(user.getName())
                        .avatarUrl(user.getAvatarUrl())
                        .role(user.getRole().name())
                        .build())
                .build();
    }
 
    public record AuthResult(AuthResponse response, String refreshToken) {}
}

Key patterns:

  • Same error message for email/password — "Invalid email or password" for both wrong email and wrong password. This prevents attackers from discovering which emails are registered.
  • Social login detection — if a user registered via Google/GitHub (no password hash), we tell them to use social login instead of trying a password.
  • AuthResult record — bundles the API response and refresh token together. The controller separates them — response goes in the body, refresh token goes in a cookie.

Auth Controller

backend/src/main/java/dev/chanhle/courses/auth/controller/AuthController.java:

package dev.chanhle.courses.auth.controller;
 
import dev.chanhle.courses.auth.dto.AuthResponse;
import dev.chanhle.courses.auth.dto.LoginRequest;
import dev.chanhle.courses.auth.dto.RegisterRequest;
import dev.chanhle.courses.auth.service.AuthService;
import dev.chanhle.courses.common.dto.ApiResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
 
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
 
    private final AuthService authService;
 
    @Value("${app.jwt.refresh-token-expiry:7d}")
    private java.time.Duration refreshTokenExpiry;
 
    @PostMapping("/register")
    public ResponseEntity<ApiResponse<AuthResponse>> register(
            @Valid @RequestBody RegisterRequest request,
            HttpServletResponse response) {
 
        AuthService.AuthResult result = authService.register(request);
        setRefreshTokenCookie(response, result.refreshToken());
 
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(ApiResponse.success("Registration successful", result.response()));
    }
 
    @PostMapping("/login")
    public ResponseEntity<ApiResponse<AuthResponse>> login(
            @Valid @RequestBody LoginRequest request,
            HttpServletResponse response) {
 
        AuthService.AuthResult result = authService.login(request);
        setRefreshTokenCookie(response, result.refreshToken());
 
        return ResponseEntity.ok(ApiResponse.success(result.response()));
    }
 
    @PostMapping("/refresh")
    public ResponseEntity<ApiResponse<AuthResponse>> refresh(
            @CookieValue(name = "refresh_token", required = false) String refreshToken,
            HttpServletResponse response) {
 
        if (refreshToken == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(ApiResponse.error("No refresh token provided"));
        }
 
        AuthService.AuthResult result = authService.refresh(refreshToken);
        setRefreshTokenCookie(response, result.refreshToken());
 
        return ResponseEntity.ok(ApiResponse.success(result.response()));
    }
 
    @PostMapping("/logout")
    public ResponseEntity<ApiResponse<Void>> logout(
            @CookieValue(name = "refresh_token", required = false) String refreshToken,
            HttpServletResponse response) {
 
        authService.logout(refreshToken);
        clearRefreshTokenCookie(response);
 
        return ResponseEntity.ok(ApiResponse.success("Logged out successfully", null));
    }
 
    @GetMapping("/me")
    public ResponseEntity<ApiResponse<AuthResponse.UserDto>> me() {
        // This endpoint requires authentication (handled by SecurityConfig)
        // The user ID is extracted from the JWT by JwtAuthenticationFilter
        Long userId = getCurrentUserId();
 
        return authService.getCurrentUser(userId)
                .map(user -> ResponseEntity.ok(ApiResponse.success(user)))
                .orElse(ResponseEntity.status(HttpStatus.NOT_FOUND)
                        .body(ApiResponse.error("User not found")));
    }
 
    private void setRefreshTokenCookie(HttpServletResponse response, String token) {
        Cookie cookie = new Cookie("refresh_token", token);
        cookie.setHttpOnly(true);
        cookie.setSecure(false); // Set to true in production (HTTPS)
        cookie.setPath("/api/auth");
        cookie.setMaxAge((int) refreshTokenExpiry.toSeconds());
        cookie.setAttribute("SameSite", "Lax");
        response.addCookie(cookie);
    }
 
    private void clearRefreshTokenCookie(HttpServletResponse response) {
        Cookie cookie = new Cookie("refresh_token", "");
        cookie.setHttpOnly(true);
        cookie.setSecure(false);
        cookie.setPath("/api/auth");
        cookie.setMaxAge(0);
        response.addCookie(cookie);
    }
 
    private Long getCurrentUserId() {
        return (Long) org.springframework.security.core.context.SecurityContextHolder
                .getContext().getAuthentication().getPrincipal();
    }
}

Cookie configuration explained:

  • httpOnly: true — JavaScript can't read the cookie (XSS protection)
  • secure: false — set to true in production (requires HTTPS)
  • path: /api/auth — cookie is only sent to auth endpoints (not every API call)
  • SameSite: Lax — cookie sent on same-site requests and top-level navigations (CSRF protection)
  • maxAge — matches the refresh token expiry (7 days)

Add the getCurrentUser method to AuthService:

public Optional<AuthResponse.UserDto> getCurrentUser(Long userId) {
    return userRepository.findById(userId)
            .map(user -> AuthResponse.UserDto.builder()
                    .id(user.getId())
                    .email(user.getEmail())
                    .name(user.getName())
                    .avatarUrl(user.getAvatarUrl())
                    .role(user.getRole().name())
                    .build());
}

OAuth2 Social Login

How OAuth2 Works

OAuth2 Configuration

Add OAuth2 client configuration to application-dev.yml:

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: openid, profile, email
            redirect-uri: "{baseUrl}/api/auth/oauth2/callback/{registrationId}"
          github:
            client-id: ${GITHUB_CLIENT_ID}
            client-secret: ${GITHUB_CLIENT_SECRET}
            scope: read:user, user:email
            redirect-uri: "{baseUrl}/api/auth/oauth2/callback/{registrationId}"

Getting OAuth2 credentials:

Set the callback URLs to http://localhost:8080/api/auth/oauth2/callback/google (and /github).

OAuth2 Success Handler

When Google/GitHub returns the user's profile, this handler creates or links the user account and redirects to the frontend with tokens:

backend/src/main/java/dev/chanhle/courses/auth/service/OAuth2AuthService.java:

package dev.chanhle.courses.auth.service;
 
import dev.chanhle.courses.auth.entity.OAuthAccount;
import dev.chanhle.courses.auth.entity.User;
import dev.chanhle.courses.auth.repository.OAuthAccountRepository;
import dev.chanhle.courses.auth.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
import java.util.Map;
 
@Service
@RequiredArgsConstructor
public class OAuth2AuthService {
 
    private final UserRepository userRepository;
    private final OAuthAccountRepository oauthAccountRepository;
    private final JwtService jwtService;
    private final RefreshTokenService refreshTokenService;
 
    @Transactional
    public AuthService.AuthResult processOAuth2Login(
            String provider, String providerAccountId,
            String email, String name, String avatarUrl) {
 
        // Check if this OAuth account already exists
        var existingOAuth = oauthAccountRepository
                .findByProviderAndProviderAccountId(provider, providerAccountId);
 
        User user;
 
        if (existingOAuth.isPresent()) {
            // Existing OAuth user — just log them in
            user = existingOAuth.get().getUser();
        } else {
            // Check if a user with this email already exists
            var existingUser = userRepository.findByEmail(email);
 
            if (existingUser.isPresent()) {
                // Link OAuth account to existing user
                user = existingUser.get();
            } else {
                // Create new user
                user = User.builder()
                        .email(email)
                        .name(name)
                        .avatarUrl(avatarUrl)
                        .role(User.Role.USER)
                        .emailVerified(true) // OAuth emails are verified
                        .build();
                user = userRepository.save(user);
            }
 
            // Create OAuth account link
            OAuthAccount oauthAccount = OAuthAccount.builder()
                    .user(user)
                    .provider(provider)
                    .providerAccountId(providerAccountId)
                    .build();
            oauthAccountRepository.save(oauthAccount);
        }
 
        String accessToken = jwtService.generateAccessToken(
                user.getId(), user.getEmail(), user.getRole().name());
        String refreshToken = refreshTokenService.createRefreshToken(user.getId());
 
        var response = dev.chanhle.courses.auth.dto.AuthResponse.builder()
                .accessToken(accessToken)
                .user(dev.chanhle.courses.auth.dto.AuthResponse.UserDto.builder()
                        .id(user.getId())
                        .email(user.getEmail())
                        .name(user.getName())
                        .avatarUrl(user.getAvatarUrl())
                        .role(user.getRole().name())
                        .build())
                .build();
 
        return new AuthService.AuthResult(response, refreshToken);
    }
 
    public Map<String, String> extractUserInfo(String provider,
                                                Map<String, Object> attributes) {
        return switch (provider) {
            case "google" -> Map.of(
                    "id", attributes.get("sub").toString(),
                    "email", attributes.get("email").toString(),
                    "name", attributes.get("name").toString(),
                    "avatar", attributes.getOrDefault("picture", "").toString()
            );
            case "github" -> Map.of(
                    "id", attributes.get("id").toString(),
                    "email", attributes.getOrDefault("email", "").toString(),
                    "name", attributes.getOrDefault("name",
                            attributes.get("login")).toString(),
                    "avatar", attributes.getOrDefault("avatar_url", "").toString()
            );
            default -> throw new IllegalArgumentException("Unsupported provider: " + provider);
        };
    }
}

OAuth2 Success Handler

backend/src/main/java/dev/chanhle/courses/config/OAuth2SuccessHandler.java:

package dev.chanhle.courses.config;
 
import dev.chanhle.courses.auth.service.AuthService;
import dev.chanhle.courses.auth.service.OAuth2AuthService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
 
import java.io.IOException;
import java.time.Duration;
import java.util.Map;
 
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
 
    private final OAuth2AuthService oAuth2AuthService;
 
    @Value("${app.frontend-url:http://localhost:3000}")
    private String frontendUrl;
 
    @Value("${app.jwt.refresh-token-expiry:7d}")
    private Duration refreshTokenExpiry;
 
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException {
 
        OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication;
        OAuth2User oAuth2User = authToken.getPrincipal();
        String provider = authToken.getAuthorizedClientRegistrationId();
 
        Map<String, String> userInfo = oAuth2AuthService.extractUserInfo(
                provider, oAuth2User.getAttributes());
 
        AuthService.AuthResult result = oAuth2AuthService.processOAuth2Login(
                provider,
                userInfo.get("id"),
                userInfo.get("email"),
                userInfo.get("name"),
                userInfo.get("avatar")
        );
 
        // Set refresh token as HTTP-only cookie
        Cookie cookie = new Cookie("refresh_token", result.refreshToken());
        cookie.setHttpOnly(true);
        cookie.setSecure(false); // true in production
        cookie.setPath("/api/auth");
        cookie.setMaxAge((int) refreshTokenExpiry.toSeconds());
        cookie.setAttribute("SameSite", "Lax");
        response.addCookie(cookie);
 
        // Redirect to frontend with access token in URL fragment
        String redirectUrl = frontendUrl + "/auth/callback#token="
                + result.response().getAccessToken();
        response.sendRedirect(redirectUrl);
    }
}

Update SecurityConfig to add OAuth2 login support:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(AbstractHttpConfigurer::disable)
        .sessionManagement(session ->
            session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/auth/**").permitAll()
            .requestMatchers("/api/health").permitAll()
            .requestMatchers(HttpMethod.GET, "/api/courses/**").permitAll()
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated()
        )
        .oauth2Login(oauth2 -> oauth2
            .authorizationEndpoint(endpoint ->
                endpoint.baseUri("/api/auth/oauth2"))
            .redirectionEndpoint(endpoint ->
                endpoint.baseUri("/api/auth/oauth2/callback/*"))
            .successHandler(oAuth2SuccessHandler)
        )
        .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
 
    return http.build();
}

Add the frontendUrl to application.yml:

app:
  frontend-url: ${FRONTEND_URL:http://localhost:3000}

Next.js Auth Context

Auth Provider

The auth context manages the access token, user state, and token refresh logic:

frontend/lib/auth.tsx:

'use client';
 
import {
  createContext,
  useContext,
  useState,
  useEffect,
  useCallback,
  ReactNode,
} from 'react';
import api from './api';
 
interface User {
  id: number;
  email: string;
  name: string;
  avatarUrl: string | null;
  role: string;
}
 
interface AuthContextType {
  user: User | null;
  accessToken: string | null;
  isLoading: boolean;
  login: (email: string, password: string) => Promise<void>;
  register: (name: string, email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  setAuth: (token: string, user: User) => void;
}
 
const AuthContext = createContext<AuthContextType | null>(null);
 
export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [accessToken, setAccessToken] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(true);
 
  // Try to refresh token on initial load
  useEffect(() => {
    refreshAuth();
  }, []);
 
  const refreshAuth = useCallback(async () => {
    try {
      const data: any = await api.post('/auth/refresh');
      setAccessToken(data.data.accessToken);
      setUser(data.data.user);
    } catch {
      setAccessToken(null);
      setUser(null);
    } finally {
      setIsLoading(false);
    }
  }, []);
 
  const login = useCallback(async (email: string, password: string) => {
    const data: any = await api.post('/auth/login', { email, password });
    setAccessToken(data.data.accessToken);
    setUser(data.data.user);
  }, []);
 
  const register = useCallback(
    async (name: string, email: string, password: string) => {
      const data: any = await api.post('/auth/register', {
        name,
        email,
        password,
      });
      setAccessToken(data.data.accessToken);
      setUser(data.data.user);
    },
    []
  );
 
  const logout = useCallback(async () => {
    try {
      await api.post('/auth/logout');
    } finally {
      setAccessToken(null);
      setUser(null);
    }
  }, []);
 
  const setAuth = useCallback((token: string, userData: User) => {
    setAccessToken(token);
    setUser(userData);
  }, []);
 
  return (
    <AuthContext.Provider
      value={{ user, accessToken, isLoading, login, register, logout, setAuth }}
    >
      {children}
    </AuthContext.Provider>
  );
}
 
export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

Axios Token Interceptor

Update frontend/lib/api.ts to attach the access token and handle automatic refresh:

import axios from 'axios';
 
const API_BASE_URL =
  process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api';
 
const api = axios.create({
  baseURL: API_BASE_URL,
  headers: {
    'Content-Type': 'application/json',
  },
  withCredentials: true,
});
 
// Token management — set by AuthProvider
let accessToken: string | null = null;
let refreshPromise: Promise<string | null> | null = null;
 
export function setAccessToken(token: string | null) {
  accessToken = token;
}
 
export function getAccessToken(): string | null {
  return accessToken;
}
 
// Request interceptor — attach access token
api.interceptors.request.use((config) => {
  if (accessToken) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
  return config;
});
 
// Response interceptor — handle 401 with automatic refresh
api.interceptors.response.use(
  (response) => response.data,
  async (error) => {
    const originalRequest = error.config;
 
    // If 401 and not already retrying, try to refresh
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
 
      try {
        // Deduplicate concurrent refresh requests
        if (!refreshPromise) {
          refreshPromise = refreshAccessToken();
        }
 
        const newToken = await refreshPromise;
        refreshPromise = null;
 
        if (newToken) {
          accessToken = newToken;
          originalRequest.headers.Authorization = `Bearer ${newToken}`;
          return api(originalRequest);
        }
      } catch {
        refreshPromise = null;
      }
    }
 
    const message =
      error.response?.data?.message || 'An unexpected error occurred';
    return Promise.reject(new Error(message));
  }
);
 
async function refreshAccessToken(): Promise<string | null> {
  try {
    const response = await axios.post(
      `${API_BASE_URL}/auth/refresh`,
      {},
      { withCredentials: true }
    );
    return response.data?.data?.accessToken || null;
  } catch {
    return null;
  }
}
 
export default api;

Key patterns:

  • refreshPromise deduplication — if multiple API calls get 401 simultaneously, only one refresh request is made. All pending calls wait for the same promise.
  • _retry flag — prevents infinite retry loops if the refresh itself fails.
  • Raw axios.post for refresh — uses a fresh axios instance to avoid triggering the interceptor recursively.

OAuth2 Callback Page

This page handles the redirect from Google/GitHub OAuth2 login:

frontend/app/auth/callback/page.tsx:

'use client';
 
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import { setAccessToken } from '@/lib/api';
 
export default function AuthCallback() {
  const router = useRouter();
  const { setAuth } = useAuth();
 
  useEffect(() => {
    // Extract token from URL fragment (#token=...)
    const hash = window.location.hash;
    const token = new URLSearchParams(hash.substring(1)).get('token');
 
    if (token) {
      setAccessToken(token);
 
      // Fetch user profile with the new token
      fetch(
        `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api'}/auth/me`,
        {
          headers: { Authorization: `Bearer ${token}` },
        }
      )
        .then((res) => res.json())
        .then((data) => {
          if (data.success) {
            setAuth(token, data.data);
            router.push('/');
          }
        })
        .catch(() => {
          router.push('/login?error=oauth_failed');
        });
    } else {
      router.push('/login?error=no_token');
    }
  }, [router, setAuth]);
 
  return (
    <div className="flex items-center justify-center min-h-[60vh]">
      <div className="text-center">
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4" />
        <p className="text-gray-600">Completing sign in...</p>
      </div>
    </div>
  );
}

Protected Route Component

frontend/components/ProtectedRoute.tsx:

'use client';
 
import { useAuth } from '@/lib/auth';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
 
interface ProtectedRouteProps {
  children: React.ReactNode;
  requiredRole?: string;
}
 
export default function ProtectedRoute({
  children,
  requiredRole,
}: ProtectedRouteProps) {
  const { user, isLoading } = useAuth();
  const router = useRouter();
 
  useEffect(() => {
    if (!isLoading && !user) {
      router.push('/login');
    }
    if (!isLoading && user && requiredRole && user.role !== requiredRole) {
      router.push('/');
    }
  }, [user, isLoading, requiredRole, router]);
 
  if (isLoading) {
    return (
      <div className="flex items-center justify-center min-h-[60vh]">
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
      </div>
    );
  }
 
  if (!user) return null;
  if (requiredRole && user.role !== requiredRole) return null;
 
  return <>{children}</>;
}

Usage:

// Any authenticated user
<ProtectedRoute>
  <DashboardPage />
</ProtectedRoute>
 
// Admin only
<ProtectedRoute requiredRole="ADMIN">
  <AdminDashboard />
</ProtectedRoute>

Login Page

frontend/app/login/page.tsx:

'use client';
 
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
 
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api';
 
export default function LoginPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const { login } = useAuth();
  const router = useRouter();
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setIsLoading(true);
 
    try {
      await login(email, password);
      router.push('/');
    } catch (err: any) {
      setError(err.message);
    } finally {
      setIsLoading(false);
    }
  };
 
  return (
    <div className="max-w-md mx-auto mt-20">
      <h1 className="text-2xl font-bold text-center mb-8">Sign In</h1>
 
      {error && (
        <div className="bg-red-50 text-red-600 p-3 rounded mb-4">{error}</div>
      )}
 
      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Email
          </label>
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            required
          />
        </div>
 
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Password
          </label>
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            required
          />
        </div>
 
        <button
          type="submit"
          disabled={isLoading}
          className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition"
        >
          {isLoading ? 'Signing in...' : 'Sign In'}
        </button>
      </form>
 
      <div className="mt-6">
        <div className="relative">
          <div className="absolute inset-0 flex items-center">
            <div className="w-full border-t border-gray-300" />
          </div>
          <div className="relative flex justify-center text-sm">
            <span className="px-2 bg-gray-50 text-gray-500">Or continue with</span>
          </div>
        </div>
 
        <div className="mt-4 grid grid-cols-2 gap-3">
          <a
            href={`${API_URL.replace('/api', '')}/api/auth/oauth2/google`}
            className="flex items-center justify-center px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
          >
            <span className="text-sm font-medium text-gray-700">Google</span>
          </a>
          <a
            href={`${API_URL.replace('/api', '')}/api/auth/oauth2/github`}
            className="flex items-center justify-center px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
          >
            <span className="text-sm font-medium text-gray-700">GitHub</span>
          </a>
        </div>
      </div>
 
      <p className="text-center text-sm text-gray-600 mt-6">
        Don't have an account?{' '}
        <a href="/register" className="text-blue-600 hover:underline">
          Sign up
        </a>
      </p>
    </div>
  );
}

Complete Auth Flow

Here's the full request flow for each auth scenario:

Email/Password Login

Token Refresh (Automatic)


Testing the Auth System

1. Register a New User

curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "name": "John Doe",
    "email": "john@example.com",
    "password": "password123"
  }' -v

Response body:

{
  "success": true,
  "message": "Registration successful",
  "data": {
    "accessToken": "eyJhbGciOiJIUzI1NiJ9...",
    "user": {
      "id": 1,
      "email": "john@example.com",
      "name": "John Doe",
      "role": "USER"
    }
  }
}

Check the Set-Cookie header — you'll see the refresh token:

Set-Cookie: refresh_token=550e8400-e29b-41d4-a716-446655440000; Path=/api/auth; HttpOnly; Max-Age=604800

2. Login

curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "john@example.com",
    "password": "password123"
  }' -c cookies.txt

3. Access Protected Endpoint

# With valid token — works
curl http://localhost:8080/api/auth/me \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."
 
# Without token — 401
curl http://localhost:8080/api/auth/me

4. Refresh Token

curl -X POST http://localhost:8080/api/auth/refresh -b cookies.txt -c cookies.txt

5. OAuth2 Login

Open in browser: http://localhost:8080/api/auth/oauth2/google

You'll be redirected to Google's consent screen, then back to your frontend.


Common Mistakes

If the refresh endpoint returns "No refresh token provided":

  • Check that withCredentials: true is set on both the Axios instance and the refresh request
  • Verify the cookie Path matches the request URL (/api/auth)
  • In Chrome DevTools → Application → Cookies, check if the cookie exists

2. CORS Error on OAuth2 Redirect

OAuth2 redirects are full page navigations (302), not AJAX requests. They don't need CORS. But if you're trying to call the OAuth2 URL via fetch() or axios, use a regular <a> link or window.location.href instead.

3. JWT Secret Too Short

If you see WeakKeyException:

The signing key's size is 128 bits which is not secure enough for the HS256 algorithm.

Your JWT_SECRET must be at least 32 characters (256 bits). Generate one:

openssl rand -base64 32

4. GitHub OAuth2 Email is Null

GitHub doesn't always include the email in the OAuth2 profile. If the user's email is private, you need to make an additional API call to /user/emails. For simplicity, you can require users to have a public email, or handle the null case by prompting them to enter their email after first OAuth2 login.


What's Next?

Authentication is complete. Users can register, log in with email or social accounts, and the frontend handles token refresh automatically. In Post #4, we'll build the course data model:

  • JPA entities for courses, sections, and lessons
  • Flyway database migrations
  • Admin CRUD API with validation
  • Slug generation and sort ordering
  • DTO mapping with MapStruct

Time to start managing content.

Series: Build a Video Streaming Platform
Previous: Phase 1: Project Setup
Next: Phase 3: Course & Lesson Data Model

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.