Foundation
For this series, our launchpad will be two separate applications: the Spring Boot-based backend and the Angular-based frontend. I am deliberately omitting their initialization because this code is just boilerplate, already in the companion repository, and you will likely find a better guide covering all the basics.
I am also deliberately omitting a lot of code here to focus on explaining key concepts, leaving repetitive or obvious parts outside of the article. You will find a complete working implementation in the repo, where everything is broken down into separate commits.
Database Setup
Before we create any application logic, we'll need a database to store user information. While Spring provides out-of-the-box support for an H2 in-memory database, which self-destructs when the application is shut down, for this project, let's focus on a more real-world implementation using PostgreSQL. The setup is as simple as possible:
image: postgres:16-alpine
environment:
POSTGRES_DB: knox_dev_db
POSTGRES_USER: knox_dev_user
POSTGRES_PASSWORD: knox_dev_pass
...
volumes:
- ./dbdev:/var/lib/postgresql/data
ports:
- '25101:5432'
[docker-compose.dev.yml]
This configuration pulls the lightweight Alpine-based image of PostgreSQL and sets up essential environment variables like the database name, username, and password. We also specify a custom port and tell Docker to persist container data right beside the file.
Don't forget to add the "volume" directory to the "gitignore" file because PostgreSQL spawns a lot of files that we don't want to be committed to the repository:
dbdev/*
[.gitignore]
Yes, committing credentials to shared storage and even storing them in plaintext is risky, but since this file is used solely to spin up a local database, it's acceptable as long as you never use these credentials in production.
Now, let's tell our backend how to connect to the database:
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://localhost:25101/knox_dev_db
username: knox_dev_user
password: knox_dev_pass
jpa:
generate-ddl: true
hibernate:
ddl-auto: update
...
[application.yml]
This configuration uses a JDBC connection string to connect to the database, which is pretty standard and allows us to pass only one variable during deployment instead of three. We also enable DDL generation to let Spring automatically update the database schema as we go.
At this point, you may already start the application with the database connected to it, but nothing much will happen yet. Let's now create an entity that will represent users in our app; it will serve as the foundation for managing users, their credentials, and other relevant information.
@Entity
@Table(name = "users")
public class User {
@NonNull
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", nullable = false, updatable = false)
private UUID id;
@Setter
@NonNull
@Column(name = "username", nullable = false, unique = true, length = 64)
@JdbcTypeCode(SqlTypes.VARCHAR)
private String username;
...
}
[User.java]
Here, we use UUIDs for the user ID to prevent guessing and to allow potential future scaling of the application into a large distributed system without major refactoring. The username field is unique and capped at 64 characters to balance out flexibility and performance; the password field is capped at the same length because of bcrypt limitations.
Pro Tip: Always use UUIDs for primary keys in distributed systems to avoid ID collisions and enhance security by preventing sequential patterns.
Now, let's create a repository for this entity, which, thanks to Spring Data's Derived Query Methods, we can declare as an interface without writing custom implementations:
public interface UserRepo extends JpaRepository<User, UUID> {
Optional<User> findFirstById(@NonNull UUID id);
Optional<User> findFirstByUsernameLikeIgnoreCase(@NonNull String username);
}
[UserRepo.java]
Finally, leverage the power of Spring Data so it will wire this entity and repository automatically:
@EntityScan({"gin.knox.jpa"})
@EnableJpaRepositories({"gin.knox.jpa"})
public class KnoxApplication {
...
}
[KnoxApplication.java]
JWT Implementation
Now, let's move on to implementing JSON Web Tokens.
First, we need to add a few dependencies that handle many things behind the scenes:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
</dependency>
[pom.xml]
Next, create a class to interchange the token information within our app. As a reminder, these tokens have only a few mandatory claims and are very extensible, but in our case, we'll focus on the subject, which is the user's ID, and the role:
public class TokenData implements Serializable {
@NonNull
private String userId;
@Nullable
private String userType;
}
[TokenData.java]
The core of the JWT implementation is the private key used for signing the token. If you've worked with production environments before, you probably know that providing secure non-text data to the application is painful. Tools like HashiCorp Vault are used in big tech to securely store various credentials, so we'll generate our keys as single-line text strings.
openssl genrsa -out ${pathPem} 2048;
keyPkcs8=$(openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in ${pathPem} | tr -d '\n');
...
keyX509=$(openssl rsa -pubout -in ${pathPem} | tr -d '\n');
...
[jwk-gen.sh]
Add the resulting keys to application.yaml
(again, that's okay if these keys are used solely in your local environment). Technically, you may set your key ID to be anything you like; this random value would be useful when you need to rotate your keys, but that's a topic for a different article.
jwt:
issuer: knoxdev
key:
id: ...
pkcs8: ...
x509: ...
[application.yml]
Next, we'll create an interface for the JwtDataProvider based on the RSAKeyProvider and provide methods to retrieve the issuer and keys for JWT handling:
public interface JwtDataProvider extends RSAKeyProvider {
@NonNull
String getIssuer();
}
[JwtDataProvider.java]
And then implement it using the @Value
annotation to inject variables from the configuration and the postConstruct
method, where our base64-encoded keys are decoded into the respective formats:
@Service
public class JwtDataProviderImpl implements JwtDataProvider {
...
@Value("${jwt.key.x509}")
private String keyX509;
@PostConstruct
private void postConstruct() throws Exception {
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
this.publicKey =
(RSAPublicKey)
keyFactory.generatePublic(new X509EncodedKeySpec(Base64.decodeBase64(this.keyX509)));
...
}
}
[JwtDataProviderImpl.java]
Now that we have our key set up, let's implement the JwtService itself:
@RequiredArgsConstructor
public class JwtServiceImpl implements JwtService {
...
}
[JwtServiceImpl.java]
At the core of this service will be an issuing method that sets the token's validity time, and consequentially the session length, the user ID and their role, our key ID, and the issuer name.
...
private @Nullable String issueToken(@NonNull String userId, @NonNull String userType) {
try {
return JWT.create()
.withKeyId(this.dataProvider.getPrivateKeyId())
.withIssuer(this.dataProvider.getIssuer())
.withExpiresAt(Instant.now().plusSeconds(sessionTtl))
.withSubject(userId)
.withClaim(claimUserType, userType)
.sign(this.algorithm);
} catch (Exception ex) {
return null;
}
}
[JwtServiceImpl.java]
Best Practice: Set reasonable expiration times for JWTs; in production, consider implementing a token blacklist or revocation mechanism to prevent compromised tokens from being reused.
This method would be called by another one that places the resulting token in a cookie. In fact, we will create two cookies – one with the token itself, which would be inaccessible for JavaScript, and one allowing the frontend to know whether the user is signed in or not.
public @Nullable Cookie[] issueCookies(@NonNull String userId, @NonNull String userType) {
String token = issueToken(userId, userType);
return token == null ? null : new Cookie[]{
this.createAuth(token, sessionTtl),
this.createMarker("1", sessionTtl - 10)
};
}
private @NonNull Cookie createAuth(@Nullable String value, int maxAge) {
Cookie cookie = new Cookie(authCookieName, value);
cookie.setPath("/");
cookie.setHttpOnly(true); // "false" for the marker
cookie.setMaxAge(maxAge);
return cookie;
}
...
[JwtServiceImpl.java]
Parsing the token is a multi-step process as well: we extract the cookie, verify the token's signature, and only then retrieve its claims. We will check that the token contains the user type as well.
...
public @Nullable TokenData parseCookies(Cookie[] cookies) {
Optional<Cookie> cookieMaybe =
cookies == null
? Optional.empty()
: Arrays.stream(cookies)
.filter(cookie -> cookie.getName().equals(authCookieName))
.findFirst();
TokenData tokenData = cookieMaybe.isEmpty() || cookieMaybe.get().getValue().isBlank()
? null
: this.parseToken(cookieMaybe.get().getValue());
return tokenData == null || tokenData.getUserType() == null ? null : tokenData;
}
private @Nullable TokenData parseToken(@NonNull String token) {
try {
DecodedJWT jwt = this.verifier.verify(token);
return new TokenData(jwt.getSubject(), jwt.getClaim(claimUserType).asString());
} catch (Exception ex) {
return null;
}
}
[JwtServiceImpl.java]
Create a RequestFilter that will authenticate our users upon each request:
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(...) {
TokenData tokenData = this.jwtService.parseCookies(request.getCookies());
SecurityContextHolder.getContext()
.setAuthentication(
tokenData == null
? new UsernamePasswordAuthenticationToken("", null, null)
: new UsernamePasswordAuthenticationToken(
tokenData.getUserId(),
null,
Collections.singleton(new SimpleGrantedAuthority(tokenData.getUserType()))));
filterChain.doFilter(request, response);
SecurityContextHolder.getContext().setAuthentication(null);
}
}
[JwtFilter.java]
Note that in this implementation, every request is considered authenticated, you may tailor this filter to your own specifics.
Finally, let's enable WebSecurity and set up the SecurityFilterChain:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(@NonNull HttpSecurity http) throws Exception {
http.addFilterBefore(this.jwtFilter, UsernamePasswordAuthenticationFilter.class);
http.authorizeHttpRequests(customizer -> customizer.anyRequest().authenticated());
http.sessionManagement(customizer -> customizer.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
http.csrf(AbstractHttpConfigurer::disable);
http.cors(Customizer.withDefaults());
return http.build();
}
...
}
[SecurityConfig.java]
While we've disabled CSRF for simplicity, keep in mind that this is an important security measure to protect against certain types of attacks.
API
Now that we have implemented JWTs for issuing and validating tokens, the next logical step is to expose this functionality through a REST API so we can at least try the code above using Postman.
First off, let's circle back to the security configuration and set up the password encoder that we will use later in the process. We don't want to store passwords in plain text.
...
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(BCryptPasswordEncoder.BCryptVersion.$2B, 10);
}
[SecurityConfig.java]
I like using simple yet handy generic classes like this one for internal stuff:
@Getter
@AllArgsConstructor
public abstract class AnyResult<T> {
private String error;
private T result;
}
[AnyResult.java]
It allows us to create classes like this, for example:
public class CookiesResult extends AnyResult<Cookie[]> {
public CookiesResult(String error, Cookie[] result) {
super(error, result);
}
public static CookiesResult error(String error) {
return new CookiesResult(error, null);
}
public static CookiesResult success(Cookie[] result) {
return new CookiesResult(null, result);
}
}
[CookiesResult.java]
We will also need some public-facing data transfer objects (aka DTOs):
public class SignupRequest {
@NotEmpty
@Size(min = 6, max = 64, message = "Incorrect username format")
@Pattern(regexp = "^[\\da-zA-Z_-]+$", message = "Incorrect username format")
private String username;
@ToString.Exclude
@NotEmpty
@Size(min = 6, max = 64, message = "Incorrect password format")
private String password;
private boolean admin;
}
[SignupRequest.java]
Here, we provide the same length limits for the username and password, add minimal requirements and some validation for usernames.
TODO: Should I mention that adding the "admin" flag for something other than a demonstration is a bad idea?
Let's tie everything together with a service to process login and signup. The "login" method is quite simple: it checks the user's credentials and issues JWT cookies if the credentials are valid.
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
...
public CookiesResult login(@NonNull LoginRequest request) {
Optional<User> userOptional = this.userRepo.findFirstByUsernameLikeIgnoreCase(request.getUsername());
if (userOptional.isEmpty()) {
return CookiesResult.error("user_not_found");
}
User user = userOptional.get();
if (!this.passwordEncoder.matches(request.getPassword(), user.getPassword())) {
return CookiesResult.error("password_mismatch");
}
return CookiesResult.success(
this.jwtService.issueCookies(
user.getId().toString(),
user.getType().toString()
));
}
...
}
[AuthServiceImpl.java]
And finally, a REST controller. You may implement the service logic right in the controller, but I prefer to keep them as simple as possible, so we'll pass the request to the service and handle the output.
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
@PostMapping(value = "/login", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> login(
@RequestBody @Valid @NotNull LoginRequest request,
HttpServletResponse res
) {
CookiesResult result = this.authService.login(request);
if (result.getError() != null) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result.getError());
}
res.addCookie(result.getResult()[0]);
res.addCookie(result.getResult()[1]);
return ResponseEntity.status(HttpStatus.OK).build();
}
...
}
[AuthController.java]
Conclusion
We've taken a hands-on approach to building a robust authentication system. From configuring PostgreSQL, to creating the User entity, to implementing JWTs and securing REST API, each component plays its role.
At this point, our application can:
- Store users in the database with the hashed passwords
- Handle their signup and login
- Validate usernames and passwords
- Issue JWTs and authenticate requests
Again, all the code fragments in this article are in the companion repository, which you can clone or browse online. If you feel any area needs further clarification in the article itself, feel free to point in the comments, and I'll do my best.
In the next articles, we will move on to the frontend part and then dive into more complex stuff to secure our users' data as much as possible.
Top comments (0)