A Spring Boot REST API secured with JWT authentication, role-based access control (RBAC), and JDBC-backed user storage using Spring Security's built-in JdbcUserDetailsManager.
Users log in with a username/password, receive a signed JWT, and use that token on subsequent requests to access role-protected endpoints.
- JWT Authentication : stateless, token-based login via
/Signin - Role-Based Access Control :
@PreAuthorizeguards onUSERandADMINendpoints - JDBC User Storage : accounts persisted through Spring Security's standard
users/authoritiesschema - Custom JWT Filter :
AuthTokenFiltervalidates the token on every request before it reaches a controller - Custom 401 Handling :
AuthEntryPointJwtreturns a clean JSON error instead of Spring's default redirect - H2 In-Memory Database : zero setup for local development, with the H2 console enabled
- BCrypt Password Hashing : no plain-text passwords, ever
- Java 17
- Spring Boot (Spring Security, Spring Web)
- JJWT : JWT creation and validation
- H2 Database : in-memory, dev/test only
- Maven
- Java 17+
- Maven 3.6+
- Git
git clone https://github.com/Ayshasharaf/Spring-Security-JWT-Authentication.git
cd Spring-Security-JWT-Authentication./mvnw clean install./mvnw spring-boot:runThe app starts on http://localhost:8080.
src/main/java/com/learn/springsecurity/
βββ SecurityConfig.java # Filter chain, password encoder, AuthenticationManager, seeds test users
βββ GreetingsController.java # /hello, /user, /admin, /Signin endpoints
βββ jwt/
βββ JwtUtils.java # Generates/validates JWTs, extracts claims (referenced by filter & controller)
βββ AuthTokenFilter.java # Runs once per request : validates JWT, sets SecurityContext
βββ AuthEntryPointJwt.java # Returns JSON 401 on failed authentication
βββ LoginRequest.java # DTO: { "username", "password" }
βββ LoginResponse.java # DTO: username, roles, JWT (referenced by controller)
src/main/resources/
βββ application.properties
βββ schema.sql # Spring Security's standard users / authorities tables
- On startup,
SecurityConfigseeds two accounts directly into the database viaJdbcUserDetailsManager:user1/Aysha@testβ roleUSERadmin/admin@testβ roleADMIN
- Client sends credentials to
POST /Signin. AuthenticationManagerverifies the credentials against the DB (BCrypt-hashed).- On success,
JwtUtilsgenerates a signed JWT and it's returned in the response body. - For every later request, the client sends
Authorization: Bearer <token>. AuthTokenFilterintercepts the request, validates the token, loads the user viaUserDetailsService, and populates Spring Security'sSecurityContext.@PreAuthorizechecks the user's roles before allowing access to/useror/admin.- If the JWT is missing/invalid,
AuthEntryPointJwtreturns a JSON401 Unauthorized.
ββββββββββ ββββββββββ
β Client β β Server β
βββββ¬βββββ βββββ¬βββββ
βββ POST /Signin {username, password} βββββββΊβ
β β validate creds (DB + BCrypt)
β β generate JWT
ββββββββββ { username, roles, token } βββββββββ
β β
βββ GET /admin (Authorization: Bearer <jwt>)βΊβ
β β AuthTokenFilter validates token
β β @PreAuthorize checks role
ββββββββββββββ "Hello Admin:)" βββββββββββββββββ
| Method | Endpoint | Description |
|---|---|---|
| GET | /hello |
Unprotected health-check endpoint |
| POST | /Signin |
Login, returns a JWT |
Login request
POST /Signin
Content-Type: application/json
{
"username": "admin",
"password": "admin@test"
}Login response
{
"username": "admin",
"roles": ["ROLE_ADMIN"],
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}Login failure (401 / 404-style error body)
{
"message": "Invalid username or password",
"status": false
}| Method | Endpoint | Required Role | Description |
|---|---|---|---|
| GET | /user |
USER |
Returns "Hello User:)" |
| GET | /admin |
ADMIN |
Returns "Hello Admin:)" |
Send the token from /Signin as a Bearer token:
GET /admin
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Unauthenticated / invalid token response
{
"status": 401,
"error": "Unauthorized",
"message": "Full authentication is required to access this resource",
"path": "/admin"
}All endpoints above were manually verified using Postman.
Flow used for testing:
POST http://localhost:8080/Signinwith the JSON body shown above (admin/admin@testoruser1/Aysha@test) β copy thetokenfrom the response.- For
GET http://localhost:8080/userorGET http://localhost:8080/admin, open the Authorization tab in Postman, set type to Bearer Token, and paste the copied token. - Confirm:
admintoken β/adminreturns200 Hello Admin:),/userreturns403 Forbiddenuser1token β/userreturns200 Hello User:),/adminreturns403 Forbidden- No token / expired / tampered token β
401 Unauthorizedwith the JSON error body fromAuthEntryPointJwt - Wrong credentials on
/Signinβ404with{ "message": "Invalid username or password", "status": false }
Spring Security's JdbcUserDetailsManager expects these exact tables:
create table users (
username varchar_ignorecase(50) not null primary key,
password varchar_ignorecase(500) not null,
enabled boolean not null
);
create table authorities (
username varchar_ignorecase(50) not null,
authority varchar_ignorecase(50) not null,
constraint fk_authorities_users foreign key(username) references users(username)
);
create unique index ix_auth_username on authorities (username, authority);| Username | Password | Role |
|---|---|---|
user1 |
Aysha@test |
USER |
admin |
admin@test |
ADMIN |
- URL:
http://localhost:8080/h2-console - JDBC URL:
jdbc:h2:mem:test - Username:
sa - Password: (leave empty)
Handy for confirming the SecurityConfig seeding and schema.sql actually ran before testing in Postman:
- Start the app, then open
http://localhost:8080/h2-consolein a browser. - On the login screen, make sure JDBC URL matches
jdbc:h2:mem:testexactly (must matchspring.datasource.url), usernamesa, password blank β Connect. - Run a quick check query:
SELECT * FROM users;
SELECT * FROM authorities;You should see user1 and admin in users, each hashed with BCrypt, and matching ROLE_USER / ROLE_ADMIN rows in authorities.
4. If the tables are missing or empty, check that spring.sql.init.mode=always and spring.jpa.defer-datasource-initialization=true are both set , otherwise schema.sql may run before Hibernate creates the schema, or not run at all.
β οΈ The H2 console is convenient for local dev but should never be exposed in a production profile , disablespring.h2.console.enabledoutside ofdev/test.
spring.application.name=SpringSecurity
# Run schema.sql after JPA initializes so the tables exist first
spring.sql.init.mode=always
spring.jpa.defer-datasource-initialization=true
# H2
spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:test
# JWT
spring.app.jwtSecret=AyshasSecretKey1092767788786867hgcgfcgcghvhjbilbmkj
spring.app.jwtExpirationMs=3000000000000000
# Logging (verbose for learning/debugging security flow)
logging.level.org.springframework=DEBUG
logging.level.org.springframework.security=DEBUG
logging.level.org.hibernate.SQL=DEBUG
β οΈ Before deploying anywhere real:
- Move
spring.app.jwtSecretinto an environment variable : never commit real secrets.spring.app.jwtExpirationMsabove is absurdly large (far beyond a validlongin practice); set a sane value such as3600000(1 hour).- Turn logging back down from
DEBUGin production.- Replace H2 with a persistent database (PostgreSQL, MySQL, etc.) : H2 here resets on every restart.
- Passwords are hashed with BCrypt before being stored : never compared or stored in plain text.
- The app is fully stateless (
SessionCreationPolicy.STATELESS) : no server-side session, the JWT carries identity on every request. - CSRF protection is disabled, which is standard practice for stateless, token-based JWT APIs (there's no session cookie to exploit).
- Framing is allowed same-origin only, just enough to let the H2 console render in an iframe.
- Failed authentication never leaks whether it was the username or the password that was wrong : both return a generic "Invalid username or password".
- Refresh token support
- Move secrets/config to environment variables
- Swap H2 for a persistent database
- Add user registration endpoint
- Rate limiting on
/Signin - Swagger/OpenAPI docs
Aysha Sharaf
- GitHub: @Ayshasharaf
MIT