APIs don’t need to remember who you are if you bring your proof every time. That’s the idea behind JSON Web Token (JWT)—a compact, signed token that carries just enough claims about the user to authorize a request. The server signs it, the client sends it in the Authorization
header, and any service with the public key can verify it. It fits SPAs, mobile apps, and microservices, scales cleanly, and makes rotation and revocation straightforward once you add refresh tokens or an auth server.
We’re going to build a small, secure API with Spring Security and store user data in MongoDB. Spring Security already knows how to handle JWTs via the OAuth2 Resource Server support, so we’ll lean on that instead of writing custom filters.
The flow is simple: Register a user, log in to get a signed JWT, and use that token to call protected endpoints. This is a starter, enough to get a real REST API locked down with stateless auth, and you can layer on refresh tokens, key rotation, and an external authorization server later. The code is all available on GitHub.
Note: While we are working on security at the application level, you can learn more about security at the database level at MongoDB University.
Prerequisites
- A MongoDB Atlas account with a cluster set up
- A free M0 cluster is perfect for this
- Java 17+
- Maven (I use version 3.9.10)
Dependencies (Spring Initializr)
Our first step is creating the application. The easiest way is with Spring Initializr: Pick Maven, Java 21, Jar.
Add these starters:
- Spring Web
- Spring Data MongoDB
- OAuth2 Resource Server (this brings in Spring Security and the Nimbus JWT pieces)
- Validation
Our pom.xml
will include:
<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-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
That’s all we need: Web for the controllers, MongoDB for persistence, the resource server starter for JWT verification and the security stack, and Validation for request DTOs.
Configuring MongoDB to store user information
Download the project from Initializr and open it in your IDE. Start by pointing Spring at MongoDB. In application.properties
, use your MongoDB Atlas connection string and name the database users
:
spring.data.mongodb.uri=${MONGODB_URI}
spring.data.mongodb.database=users
spring.data.mongodb.auto-index-creation=true
We're also telling MongoDB that we can create indexes in the app if we use them and they do not yet exist. We will annotate these using the @index
notation later. This will be useful when we want to create a unique index for our usernames to prevent duplicates.
We’re going to store user records in MongoDB. Create a model
package and add a UserAccount
document that represents what we’ll persist: a unique username, a BCrypt-hashed password, and a set of roles. We don't use the roles in this application, but this can be built upon to make some elements of the API only available to some user groups.
package com.mongodb.springjwt.model;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.Set;
@Document("users")
public class UserAccount {
@Id private String id;
@Indexed(unique = true)
private String username;
private String password; // BCrypt-hashed
private Set<String> roles; // e.g. ["USER", "ADMIN"]
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public Set<String> getRoles() {
return roles;
}
public void setRoles(Set<String> roles) {
this.roles = roles;
}
}
The @Document("users")
annotation tells Spring Data MongoDB to map this class to the users
collection and the @Indexed(unique = true)
enforces unique usernames at the database levels.
Next up is a repository so the app can talk to the database. Create a repository
package and add UserAccountRepository
. Spring Data will generate the queries for us based on method names, so we can look users up by username and quickly check if one already exists.
package com.mongodb.springjwt.repository;
import com.mongodb.springjwt.model.UserAccount;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.Optional;
public interface UserAccountRepository extends MongoRepository<UserAccount, String> {
Optional<UserAccount> findByUsername(String username);
boolean existsByUsername(String username);
}
Now, we need to teach Spring Security how to load users from MongoDB. We do that by implementing UserDetailsService
and adapting my UserAccount
document into Spring Security’s User
type.
package com.mongodb.springjwt.service;
import com.mongodb.springjwt.repository.UserAccountRepository;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;
@Service
public class MongoUserDetailsService implements UserDetailsService {
private final UserAccountRepository repo;
public MongoUserDetailsService(UserAccountRepository repo) {
this.repo = repo;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
var user = repo.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return User.withUsername(user.getUsername())
.password(user.getPassword())
.roles(user.getRoles().toArray(String[]::new))
.build();
}
}
On login, Spring calls loadUserByUsername
, we fetch the record, and if it exists, we hand back a User
with the stored BCrypt hash for the password and the roles assigned to that user. If not, we throw UsernameNotFoundException
. The DaoAuthenticationProvider
takes it from there and compares the raw password to the hash using the PasswordEncoder
.
One thing to note: .roles(...)
expects bare role names like USER
and adds the ROLE_
prefix for us, so Set.of("USER","ADMIN")
in the database becomes authorities ROLE_USER
and ROLE_ADMIN
at runtime.
Our secure API
We're adding a tiny controller with one endpoint to prove the security is actually doing something. It lives under /api/secure
and returns a greeting to whoever is authenticated. The Principal
comes from the SecurityContext
that Spring builds after a valid JWT is verified.
Create a package called controller
and a new class SecureController
:
package com.mongodb.springjwt.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.security.Principal;
@RestController
@RequestMapping("/api/secure")
public class SecureController {
@GetMapping("/hello")
public String hello(Principal principal) {
return "Hello, %s".formatted(principal.getName());
}
}
If we hit /api/secure/hello
without a token, we’ll get a 401 Unauthorized
because the resource server is in play and everything is locked down by default. When we have everything in place, we'll be able to log in to get a JWT, send it in Authorization: Bearer <token>
, and the same call responds with Hello, <username>
.
Security configuration
Here’s how we will wire security in this project. Let’s create a config
package with a single SecurityConfig
that defines the filter chain, the JWT components, and the authentication manager we'll use for the JSON login flow.
package com.mongodb.springjwt.config;
import com.nimbusds.jose.jwk.*;
import com.nimbusds.jose.jwk.source.*;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.context.annotation.*;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
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.oauth2.jwt.*;
import org.springframework.security.web.SecurityFilterChain;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/token", "/auth/register").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.build();
}
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception {
return cfg.getAuthenticationManager();
}
}
We're disabling CSRF in this tutorial because we've made this service stateless and use Bearer tokens, not cookies. If we were using server sessions, disabling CSRF would be a mistake; with stateless JWTs, it’s the right call.
The authorization rules are simple on purpose. Everything is locked down by default, with only /auth/register
and /auth/token
open so a client can create an account and exchange credentials for a token. The session policy is stateless to make every request self-contained. No server memory of prior requests, no hidden state.
The oauth2ResourceServer
piece is what actually validates tokens on protected requests. That one line adds the bearer token filter and hands it a JwtDecoder
, which we will implement soon. On each call, it pulls the token from the header, checks the signature and expiry, and only then lets the controller run. We're using self-signed JWTs here: a private key to issue them at the login endpoint, the public key to verify them on every other request. It’s simple and elegant for a single service or a starter project.
We also expose an AuthenticationManager
so the /auth/token
endpoint can accept a JSON body and call authenticate(...)
to verify the username and password against MongoDB before minting a JWT. I chose a JSON contract for login because it’s easier to evolve, adding captcha or device ID info or whatever we need.
Storing passwords securely
We're are going to add another bean to hash our passwords:
@Bean PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
We need a PasswordEncoder
(using BCryptPasswordEncoder
) because we want our user passwords to live in MongoDB as hashes, not plaintext.
If we grow our application to include refresh tokens or multiple services, we would want to split responsibilities. Put login, refresh, and key rotation in a dedicated authorization server and expose a JSON Web Key (JWK) endpoint; point our resource servers at that and keep them focused on validating tokens and serving data. Having one issuer we could reuse across different applications would mean a smaller attack surface.
We need a JWT decoder for our resource server, but before we do that, we need some RSA keys.
Creating RSA keys
We can sign JWTs with a shared secret (HS256) or with a keypair (RS256). We're using asymmetric keys so the app signs with a private key and verifies with the public key, which plays nicer with multiple services and future key rotation.
Create src/main/resources/certs
, then generate keys with OpenSSL by running the following commands in that new folder location:
openssl genrsa -out keypair.pem 2048
This will generate with an encryption level of 2048, which is fine for this demo. Do bear in mind that this is not enough for long-term security, and RSA 2028 is marked for depreciation by NIST by 2030.
This will generate the pair of keys that we can now extract the public key from, and place in a public.pem
file by running the following command in the same folder:
openssl rsa -in keypair.pem -pubout -out public.pem
If we ran our code from here, we would get an error that the private key is in the wrong format because openssl genrsa
writes a legacy PKCS#1
private key, but Java/Spring/Nimbus expect a PKCS#8
key.
To extract the private key in the format we need, we run:
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in keypair.pem -out private.pem
We should now have public.pem
and private.pem
, and we can safely delete the keypair.pem
file.
└── src/
└── main/
└── resources/certs/
keypair.pem
private.pem
public.pem
We need a way to access these keys in our application. Let's configure our application so we can bind them into the app, keep the files local in dev, and move the locations to environment variables in prod.
Create a class RsaKeyProperties
in config
package:
package com.mongodb.springjwt.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
@ConfigurationProperties(prefix = "rsa")
public record RsaKeyProperties(RSAPublicKey publicKey, RSAPrivateKey privateKey) {}
In our main SpringJwtApplication
class, we need to enable this by adding @EnableConfigurationProperties(RsaKeyProperties.class)
:
package com.mongodb.springjwt;
import com.mongodb.springjwt.config.RsaKeyProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication
@EnableConfigurationProperties(RsaKeyProperties.class)
public class SpringJwtApplication {
public static void main(String[] args) {
SpringApplication.run(SpringJwtApplication.class, args);
}
}
And in application properties:
rsa.private-key=classpath:certs/private.pem
rsa.public-key=classpath:certs/public.pem
Now that we can access our RSA keys in our app, we can create our JWT encoders and decoders.
Encoding and decoding JWT
Back in SecurityConfig
, at the top of the class can, pull the keys in via the constructor:
private final RSAPublicKey publicKey;
private final RSAPrivateKey privateKey;
public SecurityConfig(RsaKeyProperties keys) {
this.publicKey = keys.publicKey();
this.privateKey = keys.privateKey();
}
Then, define the JWT encoder and decoder:
@Bean
JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(publicKey).build();
}
@Bean
JwtEncoder jwtEncoder() {
JWK jwk = new RSAKey.Builder(publicKey).privateKey(privateKey).build();
JWKSource<SecurityContext> jwks = new ImmutableJWKSet<>(new JWKSet(jwk));
return new NimbusJwtEncoder(jwks);
}
We'll be using the NimbusJwtDecoder
and NimbusJwtEncoder
, that we have from our OAuth resource server. NimbusJwtDecoder
verifies incoming tokens with the public key; NimbusJwtEncoder
signs new tokens with the private key. When a user logs in successfully, we’ll return a token produced by this encoder.
Our next step is a small service that builds the claims and calls the encoder to mint that JWT.
Creating tokens
I keep token creation in a small service so the controller doesn’t worry about JWT details. Create a package service
and create a TokenService
class:
package com.mongodb.springjwt.service;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.stream.Collectors;
@Service
public class TokenService {
private final JwtEncoder encoder;
public TokenService(JwtEncoder encoder) {
this.encoder = encoder;
}
public String generate(Authentication auth) {
Instant now = Instant.now();
String scope = auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(" "));
var claims = JwtClaimsSet.builder()
.issuer("self")
.issuedAt(now)
.expiresAt(now.plus(1, ChronoUnit.HOURS)) // short TTL is good
.subject(auth.getName())
.claim("scope", scope)
.build();
return encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
}
The Authentication
that reaches this method represents a successfully logged-in user. We take their authorities and flatten them into a single space-delimited scope
string, then build a claim set that says who issued the token (self-assigned), when it was issued, when it expires (one hour), who it’s about (subject is our Principal.getName), and what scopes it carries.
The encoder signs that claim set with the private key and hands us back a compact JWT string, which is what we return to the caller. A one-hour expiry keeps access tokens short-lived and easy to rotate later.
Authorization controller
Time to add an auth API so clients can register and exchange credentials for a token. We keep this in a dedicated new class AuthController
in the controller
package under /auth
. It accepts JSON, checks credentials with the AuthenticationManager
, and mints a JWT with the TokenService
:
package com.mongodb.springjwt.controller;
import com.mongodb.springjwt.model.UserAccount;
import com.mongodb.springjwt.repository.UserAccountRepository;
import com.mongodb.springjwt.service.TokenService;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.*;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.Set;
@RestController
@RequestMapping("/auth")
public class AuthController {
private final UserAccountRepository users;
private final PasswordEncoder encoder;
private final AuthenticationManager authManager;
private final TokenService tokens;
public AuthController(UserAccountRepository users, PasswordEncoder encoder,
AuthenticationManager authManager, TokenService tokens) {
this.users = users;
this.encoder = encoder;
this.authManager = authManager;
this.tokens = tokens;
}
public record RegisterRequest(@NotBlank String username, @NotBlank String password) {}
public record LoginRequest(@NotBlank String username, @NotBlank String password) {}
public record JwtResponse(String token) {}
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody @Valid RegisterRequest req) {
if (users.existsByUsername(req.username())) return ResponseEntity.badRequest().body("Username taken");
var u = new UserAccount();
u.setUsername(req.username());
u.setPassword(encoder.encode(req.password()));
u.setRoles(Set.of("USER"));
users.save(u);
return ResponseEntity.ok("Registered");
}
@PostMapping("/token")
public ResponseEntity<JwtResponse> token(@RequestBody @Valid LoginRequest req) {
var auth = authManager.authenticate(new UsernamePasswordAuthenticationToken(req.username(), req.password()));
return ResponseEntity.ok(new JwtResponse(tokens.generate(auth)));
}
}
The request records keep things tight and make sure username and password aren’t empty. Registration hashes the password with BCrypt and seeds a basic USER
role before saving to MongoDB.
The token endpoint runs the credential check through Spring Security; on success, it returns a signed JWT. Hit a secured route without that token and you’ll get a 401; include Authorization: Bearer <token>
and the same call goes through.
It would be worth setting up an @RestControllerAdvice
to shape 400/401/403 responses, which makes DX nicer than plain strings, but we'll leave that out of this tutorial for simplicity.
Testing with curl
Start the app and use these calls from a terminal. I’m keeping them simple and in the exact order you’ll need.
Register a user:
curl -i -X POST http://localhost:8080/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"tim","password":"secret"}'
And we can see it in our database:
Log in and grab a token:
TOKEN=$(curl -s -X POST http://localhost:8080/auth/token \
-H "Content-Type: application/json" \
-d '{"username":"tim","password":"secret"}' | sed -E 's/.*"token":"([^"]+)".*/\1/')
echo "$TOKEN"
Call the secured endpoint with the token:
curl -i http://localhost:8080/api/secure/hello \
-H "Authorization: Bearer $TOKEN"
Try the same endpoint without a token to see the 401:
curl -i http://localhost:8080/api/secure/hello
If you want to sanity-check bad creds, this returns 401:
curl -i -X POST http://localhost:8080/auth/token \
-H "Content-Type: application/json" \
-d '{"username":"tim","password":"wrong"}'
Conclusion
We built a small but complete secure API: users in MongoDB, passwords hashed with BCrypt, a JSON login that mints short-lived RSA-signed JWTs, and protected routes enforced by Spring Security’s OAuth2 Resource Server. MongoDB fits well here because a user is just a simple document (username, hash, roles), unique indexes prevent duplicates, and you can easily extend the schema or add TTL-based refresh tokens later.
Spring Security did the heavy lifting: AuthenticationManager
+ your Mongo-backed UserDetailsService
verified credentials, PasswordEncoder
handled hashing, and the bearer token filter validated each JWT and built a SecurityContext
per request. It’s all stateless (no sessions to juggle) and ready for you to grow with issuer/audience checks, JWKS + rotation, and refresh tokens when you need them.
Top comments (0)