Spring Boot, Spring Security and JWT with React — Part 1
Spring Boot 11 min read Part of Spring Boot JWT Authentication with React
The backend foundation for a JWT-authenticated app: dependencies, JPA auditing on a shared base class, User and Role entities with the join table, and seeding roles from code instead of a SQL file.
This is the foundation part: the project, the configuration, the domain model and the repositories. No security configuration yet, and deliberately so. Spring Security is much easier to reason about once the entities it authenticates against already exist and can be inspected in a database.
By the end you will have an application that starts, creates its schema, seeds its roles, and has nothing protecting it. That is the right place to stop before adding authentication.
Written against Spring Boot 3.2, Spring Security 6.2, Java 17 and MySQL 8.
Dependencies
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</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-validation</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- JWT: API plus two runtime implementations -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
</dependencies>
Three details that trip people up on a current stack.
spring-boot-starter-validation is not transitive any more: it stopped being pulled in by the
web starter in Spring Boot 2.3. Without it, @Valid and @NotBlank are silently ignored, which is
worse than a compile error.
The MySQL driver is com.mysql:mysql-connector-j. The old mysql:mysql-connector-java coordinates
are deprecated.
JJWT ships as three artefacts, and only the API belongs on the compile classpath. Adding jjwt-impl
at compile scope lets code depend on internals that the library is explicit about not supporting.
Adding spring-boot-starter-security has an immediate effect: every endpoint is now protected by
HTTP Basic with a generated password printed at startup. That is expected, and part 2 replaces it.
Configuration
spring.datasource.url=jdbc:mysql://localhost:3306/polls?useSSL=false&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
spring.jpa.open-in-view=false
spring.jackson.serialization.write-dates-as-timestamps=false
spring.jackson.time-zone=UTC
app.jwt.secret=${JWT_SECRET}
app.jwt.expiration-ms=604800000
spring.jpa.open-in-view=false deserves its line. It defaults to true, which keeps a Hibernate
session open for the whole request so lazy collections still load inside your JSON serialiser.
That hides N+1 queries until they are a production problem, and Spring Boot logs a warning about it
at startup that almost everyone ignores. Turn it off now and fix the fetching properly.
ddl-auto=update is for development only. It never drops a column and never alters a type, so a
schema it has been maintaining slowly diverges from what the entities describe. Use a migration tool
before this reaches an environment you care about.
The JWT secret comes from the environment, with no default. A committed default signing key is a committed private key, and it will be in the repository history long after someone changes it.
Auditing, on a base class
Every table wants created and updated timestamps, and writing them by hand in every entity is how they end up inconsistent:
package com.example.polls.model.audit;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.persistence.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.io.Serializable;
import java.time.Instant;
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@JsonIgnoreProperties(value = {"createdAt", "updatedAt"}, allowGetters = true)
public abstract class DateAudit implements Serializable {
@CreatedDate
@Column(nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(nullable = false)
private Instant updatedAt;
// getters and setters
}
@MappedSuperclass contributes columns to subclasses without being an entity itself. There is no
date_audit table. @EntityListeners(AuditingEntityListener.class) is what actually populates the
fields.
allowGetters = true is the useful part of that Jackson annotation: the timestamps are serialised
out to clients but ignored on the way in, so a request cannot set its own createdAt.
Auditing needs enabling once:
@SpringBootApplication
@EnableJpaAuditing
public class PollsApplication {
public static void main(String[] args) {
SpringApplication.run(PollsApplication.class, args);
}
}
Miss that annotation and both columns stay null while nullable = false rejects the insert. The
resulting constraint violation names the column but not the cause.
Use Instant, not Date. Storing an instant and formatting per user is the only arrangement that
survives contact with more than one timezone.
User and Role
@Entity
@Table(name = "users", uniqueConstraints = {
@UniqueConstraint(columnNames = "username"),
@UniqueConstraint(columnNames = "email")
})
public class User extends DateAudit {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(max = 40)
private String name;
@NotBlank
@Size(max = 15)
private String username;
@NaturalId
@NotBlank
@Size(max = 40)
@Email
private String email;
@NotBlank
@Size(max = 100)
private String password;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id"))
private Set<Role> roles = new HashSet<>();
// constructors, getters and setters
}
@Entity
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@NaturalId
@Column(length = 60)
private RoleName name;
// constructors, getters and setters
}
public enum RoleName {
ROLE_USER,
ROLE_ADMIN
}
Four decisions in there worth stating.
Unique constraints in the mapping, not only in validation. A @Column(unique = true) produces a
database constraint; a validator check does not, and two concurrent registrations will both pass a
“does this username exist” query before either commits.
@Enumerated(EnumType.STRING). The default is ORDINAL, which stores the enum’s position as an
integer. Insert a new constant in the middle of the enum later and every existing row silently means
something different. Always name the string form.
password is 100 characters because it stores a BCrypt hash, not a password. A BCrypt hash is
60 characters; sizing this field to a plausible password length is a classic and confusing failure.
Roles are LAZY. They are loaded when authentication needs them, not on every query that
touches a user.
The ROLE_ prefix is not decoration either, Spring Security’s hasRole("ADMIN") looks for an
authority named ROLE_ADMIN. Storing ADMIN and wondering why authorisation fails is a rite of
passage.
Repositories
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Optional<User> findByUsernameOrEmail(String username, String email);
Boolean existsByUsername(String username);
Boolean existsByEmail(String email);
List<User> findByIdIn(List<Long> userIds);
}
public interface RoleRepository extends JpaRepository<Role, Long> {
Optional<Role> findByName(RoleName roleName);
}
Optional rather than a nullable return, and existsBy… rather than fetching a row to check for
its presence, the second issues a count query and transfers nothing.
Seeding the roles
The roles table has to contain both rows before anyone can register, and a hand-run INSERT is a
step someone will forget on a fresh environment. Do it in code, idempotently:
@Component
public class RoleSeeder implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(RoleSeeder.class);
private final RoleRepository roles;
public RoleSeeder(RoleRepository roles) {
this.roles = roles;
}
@Override
public void run(ApplicationArguments args) {
for (RoleName name : RoleName.values()) {
roles.findByName(name).orElseGet(() -> {
log.info("seeding role {}", name);
return roles.save(new Role(name));
});
}
}
}
Driven off RoleName.values(), so adding a constant to the enum is the only change needed. data.sql
would also work and is fine for a fixed set, but it does not adapt and it runs before JPA has
finished creating the schema unless you order it explicitly.
Checking it works
$ ./mvnw spring-boot:run
Using generated security password: 8f4a1c02-...
seeding role ROLE_USER
seeding role ROLE_ADMIN
mysql> show tables;
+------------------+
| roles |
| user_roles |
| users |
+------------------+
mysql> select id, name from roles;
+----+------------+
| 1 | ROLE_USER |
| 2 | ROLE_ADMIN |
+----+------------+
Three tables, two roles, and every endpoint behind the generated Basic password. Restart the
application and the seeder logs nothing. The orElseGet did its job.
What comes next
The foundation is in place: entities that can be authenticated against, repositories to look them up
by username or email, and roles to authorise with. The next part replaces the generated Basic
password with a SecurityFilterChain, a UserDetailsService backed by UserRepository, a BCrypt
encoder, and a filter that validates a signed token on every request.
Two things worth doing before then: confirm the user_roles join table is populated when you attach
a role to a user, and keep JWT_SECRET out of the repository from the beginning rather than
retrofitting it later.
Frequently asked questions
Why is every endpoint asking for a password already?
Adding spring-boot-starter-security
secures everything by default with HTTP Basic and a generated password logged at startup. That is the
intended state until a SecurityFilterChain replaces it.
Why are createdAt and updatedAt null?
@EnableJpaAuditing is missing from a configuration class.
The annotations do nothing without it, and nullable = false then rejects the insert.
Why does the password column need 100 characters?
It stores a BCrypt hash, which is 60 characters, not a plaintext password. Sizing it to password length truncates the hash and every login fails.
Do roles need the ROLE_ prefix?
Yes, if you use hasRole("ADMIN"), Spring Security prepends
ROLE_ when matching. Store ROLE_ADMIN, or use hasAuthority("ADMIN") consistently instead.
Why EnumType.STRING instead of the default?
The default ORDINAL stores the enum’s index.
Reordering or inserting a constant later changes the meaning of every existing row, with no error.
What is @NaturalId for?
It marks a business key that is unique and stable. Hibernate can then offer a natural-id lookup and cache by it; it also documents which field identifies the row to a human.
Should I disable open-in-view?
Yes. Left on. It holds a session open for the whole request so lazy loading silently works during serialisation, hiding N+1 queries until they hurt.
Is ddl-auto=update safe?
For local development only. It never drops or alters, so the schema drifts from the entities. Use Flyway or Liquibase for anything shared.
Should the JWT secret have a default value?
No. Read it from the environment with no fallback. A default signing key committed to a repository stays in its history permanently.
Why three JJWT dependencies?
jjwt-api is the compile-time API; jjwt-impl and jjwt-jackson
are runtime implementations. Keeping the last two at runtime scope stops code depending on
unsupported internals.
Where should I go next?
Spring Boot REST API covers the controller and repository layers in more depth, and OAuth2 social login is the alternative authentication route on the same stack.