Cómo construir una API RESTful segura con Spring Boot y JWT (paso a paso)
Tutorial práctico para crear desde cero una API con Spring Boot protegida por JSON Web Tokens (JWT). Incluye estructura de carpetas, configuración de seguridad, generación/validación de tokens, ejemplos de endpoints y comandos para probarla. Está pensada para desarrolladores que ya conocen Java y Maven.
Requisitos
- JDK 11+
- Maven
- Conocimientos básicos de Spring Boot, Spring Security y JPA
Estructura de carpetas
src/main/java/com/example/demo
├── DemoApplication.java
├── config
│ └── SecurityConfig.java
├── controller
│ ├── AuthController.java
│ └── UserController.java
├── dto
│ ├── LoginRequest.java
│ └── SignupRequest.java
├── model
│ └── User.java
├── repository
│ └── UserRepository.java
├── security
│ ├── JwtFilter.java
│ └── JwtProvider.java
└── service
└── UserService.java
src/main/resources
├── application.properties
pom.xml (dependencias clave)
<project ...>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.0</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
</dependency>
</dependencies>
</project>
application.properties (mínimo)
server.port=8080
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
# JWT
app.jwt.secret=MiSecretoUltraSeguroQueDebeSerMuyLargoYGuardadoEnVault
app.jwt.expiration-ms=3600000 # 1 hora
Model: User (simplificado)
package com.example.demo.model;
import javax.persistence.*;
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String password; // almacenado con bcrypt
// getters y setters
}
Repository
package com.example.demo.repository;
import com.example.demo.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
}
DTOs
package com.example.demo.dto;
public class LoginRequest {
public String username;
public String password;
}
public class SignupRequest {
public String username;
public String password;
}
Servicio: UserService
package com.example.demo.service;
import com.example.demo.model.User;
import com.example.demo.repository.UserRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public User createUser(String username, String rawPassword) {
if (userRepository.existsByUsername(username)) throw new RuntimeException("Usuario ya existe");
User u = new User();
u.setUsername(username);
u.setPassword(passwordEncoder.encode(rawPassword));
return userRepository.save(u);
}
public User findByUsername(String username) {
return userRepository.findByUsername(username).orElse(null);
}
}
JWT Provider (generar/validar)
package com.example.demo.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.security.Key;
import java.util.Date;
@Component
public class JwtProvider {
@Value("${app.jwt.secret}")
private String jwtSecret;
@Value("${app.jwt.expiration-ms}")
private long jwtExpirationMs;
private Key key;
@PostConstruct
public void init() {
// Usar llave simétrica derivada del secreto
key = Keys.hmacShaKeyFor(jwtSecret.getBytes());
}
public String generateToken(String username) {
Date now = new Date();
Date expiry = new Date(now.getTime() + jwtExpirationMs);
return Jwts.builder()
.setSubject(username)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(key)
.compact();
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
public String getUsernameFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build()
.parseClaimsJws(token).getBody().getSubject();
}
}
JwtFilter (intercepta peticiones y autentica)
package com.example.demo.security;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.User;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
public class JwtFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private final com.example.demo.service.UserService userService;
public JwtFilter(JwtProvider jwtProvider, com.example.demo.service.UserService userService) {
this.jwtProvider = jwtProvider;
this.userService = userService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtProvider.validateToken(token)) {
String username = jwtProvider.getUsernameFromToken(token);
com.example.demo.model.User u = userService.findByUsername(username);
if (u != null) {
UserDetails userDetails = User.withUsername(u.getUsername()).password(u.getPassword()).authorities(Collections.emptyList()).build();
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}
SecurityConfig
package com.example.demo.config;
import com.example.demo.security.JwtFilter;
import com.example.demo.security.JwtProvider;
import com.example.demo.service.UserService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
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
public class SecurityConfig {
private final JwtProvider jwtProvider;
private final UserService userService;
public SecurityConfig(JwtProvider jwtProvider, UserService userService) {
this.jwtProvider = jwtProvider;
this.userService = userService;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
JwtFilter jwtFilter = new JwtFilter(jwtProvider, userService);
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/auth/**").permitAll()
.anyRequest().authenticated();
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
}
AuthController
package com.example.demo.controller;
import com.example.demo.dto.LoginRequest;
import com.example.demo.dto.SignupRequest;
import com.example.demo.model.User;
import com.example.demo.security.JwtProvider;
import com.example.demo.service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/auth")
public class AuthController {
private final UserService userService;
private final JwtProvider jwtProvider;
private final AuthenticationManager authenticationManager;
public AuthController(UserService userService, JwtProvider jwtProvider, AuthenticationManager authenticationManager) {
this.userService = userService;
this.jwtProvider = jwtProvider;
this.authenticationManager = authenticationManager;
}
@PostMapping("/signup")
public ResponseEntity<?> signup(@RequestBody SignupRequest req) {
User u = userService.createUser(req.username, req.password);
return ResponseEntity.ok("Usuario creado: " + u.getUsername());
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest req) {
try {
authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(req.username, req.password));
String token = jwtProvider.generateToken(req.username);
return ResponseEntity.ok(java.util.Collections.singletonMap("token", token));
} catch (AuthenticationException e) {
return ResponseEntity.status(401).body("Credenciales inválidas");
}
}
}
Un endpoint protegido: UserController
package com.example.demo.controller;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/me")
public Object me(@AuthenticationPrincipal UserDetails user) {
return java.util.Collections.singletonMap("username", user.getUsername());
}
}
Cómo probarlo (curl)
# 1) Registrar usuario
curl -X POST http://localhost:8080/auth/signup -H "Content-Type: application/json" -d '{"username":"alice","password":"pass123"}'
# 2) Loguear y obtener token
curl -X POST http://localhost:8080/auth/login -H "Content-Type: application/json" -d '{"username":"alice","password":"pass123"}'
# Respuesta: {"token":"eyJhbGci..."}
# 3) Acceder endpoint protegido
curl http://localhost:8080/api/users/me -H "Authorization: Bearer eyJhbGci..."
Por qué estas decisiones
- Usamos JWT para evitar estado en el servidor (stateless). Ideal para microservicios y clientes SPAs.
- BCrypt para almacenar contraseñas: costo ajustable y ampliamente probado.
- Filtro JWT propio (OncePerRequestFilter): controla la extracción y validación antes del procesamiento del request.
- Secret largo y almacenado fuera del repositorio (config en Vault/Secrets manager recomendado).
- H2 se usa sólo para demo; en producción usar RDS/Postgres y migraciones (Flyway/Liquibase).
Errores comunes y recomendaciones de seguridad
- No almacenar secretos en repositorios: usar variables de entorno o secret managers.
- No confiar únicamente en la expiración: implementar revocación o uso de refresh tokens si necesitas logout forzado.
- Evitar poner datos sensibles en el payload del JWT.
- Configurar CORS correctamente para APIs públicas.
Cómo ejecutar
- mvn clean package
- java -jar target/demo-0.0.1-SNAPSHOT.jar
Con esto tendrás una API funcional y básica con autenticación JWT. Para producción: agrega pruebas unitarias/integración, manejo de excepciones estándar, validación de request DTOs (javax.validation), y rotación segura de claves.
Tip avanzado: considera emitir tokens cortos (ej. 15 min) y usar refresh tokens rotativos almacenados en base de datos con un identificador no predecible para permitir revocación y mitigación de robo de tokens.
Advertencia: este tutorial muestra patrones recomendados pero omite detalles de hardening en producción; revisa OWASP API Security y adapta la configuración a tu contexto.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación