DEV Community

Cover image for Secure Your Spring API With JWT and MongoDB
Tim Kelly for MongoDB

Posted on

Secure Your Spring API With JWT and MongoDB

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

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

Spring Initializr config

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;  
    }  
}
Enter fullscreen mode Exit fullscreen mode

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);  
}
Enter fullscreen mode Exit fullscreen mode

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();  
    }  
}
Enter fullscreen mode Exit fullscreen mode

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());  
    }  
}
Enter fullscreen mode Exit fullscreen mode

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();  
    }  
}
Enter fullscreen mode Exit fullscreen mode

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();  
    }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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  
Enter fullscreen mode Exit fullscreen mode

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) {}
Enter fullscreen mode Exit fullscreen mode

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);  
    }  

}
Enter fullscreen mode Exit fullscreen mode

And in application properties:

rsa.private-key=classpath:certs/private.pem  
rsa.public-key=classpath:certs/public.pem
Enter fullscreen mode Exit fullscreen mode

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();  
    }  
Enter fullscreen mode Exit fullscreen mode

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);  
    }  
Enter fullscreen mode Exit fullscreen mode

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();  
    }  
}
Enter fullscreen mode Exit fullscreen mode

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)));  
    }  
}
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

And we can see it in our database:

Image of document in the 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"
Enter fullscreen mode Exit fullscreen mode

Call the secured endpoint with the token:

curl -i http://localhost:8080/api/secure/hello \
  -H "Authorization: Bearer $TOKEN"
Enter fullscreen mode Exit fullscreen mode

Try the same endpoint without a token to see the 401:

curl -i http://localhost:8080/api/secure/hello
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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)