MorePro· 60 min read

JWT Authentication & Role-Based Access

Modern APIs do not send a password on every request — they log in once, get a signed token, and send that token instead. Here is how JWT works in Spring Security 6.

What you will learn

  • Explain how token-based (JWT) auth works versus Basic Auth
  • Hash passwords with BCrypt and issue a JWT on login
  • Restrict endpoints by role (RBAC)

Why not just use Basic Auth?

Basic Auth (last lesson) sends the username and password with every single request. That is simple but has real downsides: the password travels constantly, the server must check it every time, and there is no easy way to log out or expire access. Modern APIs use tokens instead.

A JWT (JSON Web Token) is a small, signed string the server gives you when you log in. After that, you send the token — not your password — on every request. Signed means the server can verify the token came from it and was not tampered with, so it trusts it without a database lookup.

Basic AuthJWT (token)
What is sent each requestUsername + passwordA signed token
Password travelsEvery requestOnly once, at login
Server stores a session?NoNo (stateless)
Can expire / log outHardYes — tokens expire

The JWT flow, step by step

Picture logging into an app and then using it. There are two phases — getting a token, then using it:

  1. Login: the client sends username + password once to POST /login.
  2. The server checks the password (against the BCrypt hash stored in the database) and, if correct, creates and signs a JWT and returns it.
  3. The client stores the token and, from now on, sends it on every request in a header: Authorization: Bearer <token>.
  4. On each request, a security filter reads the token, verifies the signature, and identifies the user — no password, no database session needed.
  5. If the token is valid and not expired, the request proceeds; otherwise the server returns 401 Unauthorized.

First: never store raw passwords — hash with BCrypt

You must never store a user’s real password. Instead you store a hash: a one-way scramble that cannot be reversed back into the password. BCrypt is the standard hashing algorithm in Spring. At login you hash the entered password and compare hashes.

Hashing and checking a password with BCrypt
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();   // the standard hasher
}

// When a user registers — store the HASH, never the raw password:
String hash = passwordEncoder.encode("secret123");
// hash looks like: $2a$10$N9qo8uLOickgx2ZMRZoMy...

// At login — check the entered password against the stored hash:
boolean ok = passwordEncoder.matches("secret123", hash);  // true

Note: Output: The stored hash is a long scrambled string, never the word "secret123". matches("secret123", hash) returns true; matches("wrong", hash) returns false. BCrypt is one-way: even if your database is stolen, attackers cannot read the original passwords. You only ever compare hashes.

Issue a JWT on successful login

When the password checks out, build and sign a token. A small helper (using a JWT library such as jjwt) does this. The token carries the username and an expiry time, signed with a secret key only the server knows:

A /login endpoint that returns a signed JWT
@RestController
public class AuthController {
    private final JwtService jwt;          // your helper that builds tokens
    private final AuthService auth;        // checks user + BCrypt password

    public AuthController(JwtService jwt, AuthService auth) {
        this.jwt = jwt; this.auth = auth;
    }

    @PostMapping("/login")
    public Map<String, String> login(@RequestBody LoginRequest req) {
        auth.verify(req.getUsername(), req.getPassword());  // throws 401 if wrong
        String token = jwt.createToken(req.getUsername()); // signed, e.g. 1h expiry
        return Map.of("token", token);
    }
}

Note: Output: POST /login {"username":"admin","password":"secret123"} -> {"token":"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiI...signature"} The client saves that token. A JWT has three dot-separated parts (header.payload.signature). The signature is what the server later verifies — if anyone changes the payload, the signature no longer matches and the token is rejected.

Send the token on later requests

From now on the client sends the token in the Authorization header. A security filter validates it on every request:

Sending the JWT as a Bearer token
curl http://localhost:8080/products \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiI..."

Note: Output: Valid token -> 200 OK with the JSON data Missing token -> 401 Unauthorized Expired token -> 401 Unauthorized The server verifies the signature and expiry on each request. No password is sent, and the server keeps no session — this is what "stateless" means.

Role-based access (RBAC)

RBAC (Role-Based Access Control) means different users can do different things based on their role — e.g. only an ADMIN can delete. In Spring Security 6 you wire this in the security config, restricting paths or methods by role:

Rules: open login, public reads, admin-only deletes
@Bean
public SecurityFilterChain chain(HttpSecurity http) throws Exception {
    http
      .csrf(csrf -> csrf.disable())                 // common for token APIs
      .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
      .authorizeHttpRequests(a -> a
        .requestMatchers("/login").permitAll()       // anyone may log in
        .requestMatchers(HttpMethod.GET, "/products").permitAll()
        .requestMatchers(HttpMethod.DELETE, "/products/**").hasRole("ADMIN")
        .anyRequest().authenticated());              // everything else needs a token
    // ... add your JWT filter here ...
    return http.build();
}

Note: Output: GET /products (no token) -> 200 OK (reads are public) DELETE /products/5 (a normal user) -> 403 Forbidden (not an admin) DELETE /products/5 (an admin token) -> 200 OK 401 means "not logged in"; 403 means "logged in, but not allowed". Here only an ADMIN role may delete.

Tip: Two status codes you must not mix up: 401 Unauthorized = “I do not know who you are” (no/invalid token); 403 Forbidden = “I know who you are, but you are not allowed to do this” (wrong role). Reading them right makes debugging auth much faster.

Watch out: Keep your signing secret truly secret and long — anyone who has it can forge valid tokens. Store it in an environment variable, never in committed code. Also give tokens a short expiry (e.g. one hour) so a leaked token is useful for only a short time.

Q. In JWT-based auth, when does the client send the actual password?

Answer: With JWT, the password is sent only once at login. The server returns a signed token, and the client sends that token (not the password) on every later request — which is stateless and lets tokens expire.

✍️ Practice

  1. Add a BCryptPasswordEncoder bean and use it to hash a password and verify it with matches.
  2. Write a /login endpoint (conceptually) that checks a user and returns a token string.

🏠 Homework

  1. Design the security rules for your Task API: /login is open, reading tasks is public, but creating/updating/deleting requires a valid JWT and the ADMIN role. Write out which status code (401 vs 403) each failing case returns.
Want to learn this with a mentor?

CodingClave runs guided, project-based training (28-day, 45-day & 6-month batches).

Explore Training →