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
| Token | Storage | Lifetime | Purpose |
|---|---|---|---|
| Access token | JavaScript memory (variable) | 15 minutes | Authenticate API requests |
| Refresh token | HTTP-only secure cookie | 7 days | Obtain 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: 7dImportant: 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:
- Validate the old refresh token
- Delete it from Redis (one-time use)
- Create a new refresh token
- 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_forhasRole()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 theADMINrole - 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.
AuthResultrecord — 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 totruein 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:
- Google: Go to Google Cloud Console → APIs & Services → Credentials → Create OAuth 2.0 Client ID
- GitHub: Go to GitHub Settings → OAuth Apps → New OAuth App
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:
refreshPromisededuplication — if multiple API calls get 401 simultaneously, only one refresh request is made. All pending calls wait for the same promise._retryflag — prevents infinite retry loops if the refresh itself fails.- Raw
axios.postfor 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"
}' -vResponse 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=6048002. Login
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"password": "password123"
}' -c cookies.txt3. 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/me4. Refresh Token
curl -X POST http://localhost:8080/api/auth/refresh -b cookies.txt -c cookies.txt5. 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
1. Refresh Token Cookie Not Sent
If the refresh endpoint returns "No refresh token provided":
- Check that
withCredentials: trueis set on both the Axios instance and the refresh request - Verify the cookie
Pathmatches 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 324. 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.