# Jwt-Spring-Security-JPA: a worked example of JWT authentication in Spring

> Jwt-Spring-Security-JPA is a Spring Boot demonstration of stateless authentication with JWT, Spring Security and JPA, and it spells out the parts that are usually glossed over, including email verification, refresh tokens, multi-device logout and the in-memory token blacklist. It is teaching material, with a MySQL story in the documentation, a PostgreSQL service in the Compose file, and a database that is recreated on every run.

**isopropylcyanide/Jwt-Spring-Security-JPA** — Backend MVP showcasing JWT (Json Web Token) authentication with multiple login,  timeout / refresh / logout (with in memory invalidation) using Spring Security & MySQL JPA.  

- Repository: https://github.com/isopropylcyanide/Jwt-Spring-Security-JPA
- Stars: 555 · Forks: 211
- Language: Java
- License: Apache-2.0
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/isopropylcyanide-jwt-spring-security-jpa

## A demonstration project, and what it sets out to show

Jwt-Spring-Security-JPA describes itself as a demo project explaining backend authentication with JWT, JSON Web Tokens, using Spring Security and MySQL JPA. The word demo is doing real work in that sentence, and it is worth reading the rest of the project with that framing in mind.

The feature list is what makes it a useful reference. It covers conventional email or username based registration with admin support, conventional login using Spring Security with generation of a JWT, multiple device login and logout support, an in-memory store for blacklisting JWT tokens on logout, expiration based email verification with the mail sent at registration, resending the confirmation mail when the old one expires, a forgot-password flow with password reset token validation, admin protected URLs built on Spring Security, refreshing JWT tokens once the temporary token expires, and a check for whether a username or email is already taken during registration.

That list is a coherent account of what a real authentication service needs beyond the login call, and the topics attached to the repository name Spring Security, JWT, refresh tokens, Spring Boot JPA, Spring events and Spring Security WebAuthn, so there is more in the code than the README sets out. The project is written in Java, released under Apache-2.0, and its last push was on 2026-06-10 with no tagged releases and no separate home page, so the repository itself is the documentation.

## Registration, login and the device block that drives multi device logout

The API is small and readable. Registration is a POST to /api/auth/register with an email, a password and a registerAsAdmin flag, and a second registration of the same address produces an email in use error:

```bash
curl --location --request POST 'localhost:9004/api/auth/register' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "amangarg1995sep@gmail.com",
    "password": "amangarg",
    "registerAsAdmin": true
}'
```

Login posts to /api/auth/login with the same credentials plus a deviceInfo object:

```bash
curl --location --request POST 'localhost:9004/api/auth/login' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "amangarg1995sep@gmail.com",
    "password": "amangarg",
    "deviceInfo": {
        "deviceId": "D1",
        "deviceType": "DEVICE_TYPE_ANDROID",
        "notificationToken": "N1"
    }
}'
```

The deviceInfo block is the interesting part of the design. It carries a device identifier, a device type such as DEVICE_TYPE_ANDROID, and a notification token, and the documentation is explicit that device information is required to enable the multi device login and logout functionality. That is the mechanism by which a logout can be scoped to one device rather than to a user, and the notification token is presumably what ties a push notification to that device session.

The ordering of the flow is enforced too. An unverified user can reach the login endpoint, but the documentation lists the outcomes: bad credentials produce a Bad credentials error, and an unverified email produces an Unauthorized error, so verification gates the token that matters.

## Email verification, reset tokens and where the token actually goes

Verification is a separate GET endpoint rather than part of registration, and it takes the token as a query parameter:

```bash
curl --location --request GET 'localhost:9004/api/auth/registrationConfirmation?token=bcbf8764-dbf2-4676-9ebd-2c74436293b9' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "a@b.com",
    "password": "HI12",
    "deviceInfo": {
        "deviceId": "D1",
        "deviceType": "DEVICE_TYPE_ANDROID",
        "notificationToken": "N1"
    }
}'
```

An incorrect token gives a Token Mismatch error. The way to obtain the token in a local run is documented in a slightly unusual way: check your email settings in mail.properties, and if the mail has not arrived, look inside the database table email_verification_token and read the token column. That is a development convenience, and it is also a reminder that the verification token is stored server side, which is what lets an expired confirmation be resent.

The password reset flow works the same way around a reset token with its own validation, and the project describes the mail as sent upon registration with an expiry, with a resend path for the case where the old confirmation has expired. Since the token is expiry based rather than permanent, the flow has to handle a user who registers and never confirms, and the resend endpoint is the answer.

## The token error taxonomy is the part worth copying

Once a token exists, the interesting material is how failures are reported, because that is what a client has to handle. The documentation lists four distinct outcomes for the bearer token used against a protected resource, and the resource in question is /api/user/me:

```bash
curl --location --request GET 'localhost:9004/api/user/me' \
--header 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiaWF0IjoxNjM1NjE0NTY4LCJleHAiOjE2MzU2MTU0Njh9.d8CJYduoC44njutphODoezheSt_so3Doc9g1RSiMaDU_qJwY0_3Ym4092hFkHsh-jbyB_9i66LbwSEE-szAgEw'
```

An invalid token gives an Incorrect JWT Signature error, a malformed token gives a Malformed JWT Signature error, and an expired token gives an Expired JWT Signature error, with the client expected to refresh the JWT token when that happens. The expiry is configurable through app.jwt.expiration. Separating invalid from malformed from expired is a small design decision with real consequences for a client, because only the third case is recoverable by refreshing, and a client that treats all three the same will log people out unnecessarily.

Authorisation is a separate axis from authentication. There is an admin resource, /api/user/admins, reached with the same bearer header, and a user who registered with registerAsAdmin set to false gets a Forbidden error. The distinction between Unauthorized and Forbidden in the responses mirrors the distinction in the Spring Security configuration underneath.

The project also documents the JWT standard itself, describing JSON Web Tokens as an open, industry standard RFC 7519 method for representing claims securely between two parties, and the sample header decodes to HS512, so the signing is a shared secret HMAC.

## Running it locally: a database, two property files and a Maven wrapper

The setup sequence is short. Clone the repository and change into it:

```bash
$ git clone https://github.com/isopropylcyanide/Jwt-Spring-Security-JPA.git
$ cd Jwt-Spring-Security-JPA
```

Then create the database, which the documentation gives as a single statement:

```bash
$ create database login_db
```

Two property files then need editing. The datasource credentials spring.datasource.username and spring.datasource.password live in src/main/resources/application.properties and have to match your MySQL installation, and the mail credentials spring.mail.username and spring.mail.password live in src/main/resources/mail.properties and have to match your mail server. The mail file is also where the local email verification token shows up, as described earlier.

The application is started with the Maven wrapper, and the wrapper scripts are in the repository root:

```shell
./mvnw spring-boot:run   # For UNIX/Linux based operating systems
mvnw.cmd spring-boot:run # For Windows based operating systems
```

The server listens on server.port 9004 and creates its tables for you. The line to notice is the next one: every run of the app resets the state, and to stop that you change spring.jpa.hibernate.ddl-auto to update. That default is appropriate for a demo and wrong for anything else, because it means a restart wipes users, tokens and the registration records.

The API is also documented through a Swagger docket that exposes the endpoints with their schemas, reachable at http://localhost:9004/swagger-ui once the app is running, which is a faster way to explore the surface than reading the curl examples.

## compose.yml starts PostgreSQL, not the MySQL the documentation describes

The Compose file in the repository root contradicts the README in a way that will cost you an afternoon if you do not notice it. The MySQL service is present but entirely commented out, including its image, its container name, its environment, the port mapping 3309 to 3306, the volume for ./mysql_data and the healthcheck. What is active is a PostgreSQL service:

```yaml
  postgres:
    image: postgres:17.5-alpine3.22
    container_name: postgres175
    environment:
      POSTGRES_DB: login_db
      POSTGRES_USER: yu71
      POSTGRES_PASSWORD: 53cret
    ports:
      - "5433:5432"
```

The database name matches the one the documentation tells you to create, but the engine does not, and the port mapping is 5433 on the host rather than 3309, so application.properties has to point at a different JDBC URL than the one a MySQL setup needs. The Compose file also defines a named bridge network called auth-network and a volume for the PostgreSQL data directory.

Two things follow for anyone using this as a starting point. The database password and the user name are written in the file in plain text, which is acceptable for a local demo and is exactly the pattern you must not carry into a real deployment. And because the active service differs from the documented one, the first run of the project will not work until you decide which database you actually want, and adjust the datasource properties to match.

## The limits of an in-memory blacklist and a schema that recreates itself

Several features in this project are demonstrations of an idea rather than implementations you would ship, and the documentation is reasonably honest about the first of them. The blacklist of JWT tokens on logout is an in-memory store, which means it disappears when the process restarts, and it is per instance, which means a load balanced deployment has one blacklist per node. The consequence is that a token invalidated on one node can still be accepted by another, and after a restart every previously invalidated token is valid again. That is the correct behaviour for a stateless JWT design to be replaced with something durable, and the project is showing you the seam where that has to go.

The second limit is the schema. With the default ddl-auto setting, the application drops and recreates its tables on every start, so the users, the refresh tokens and the email verification records are transient. The documentation tells you to change it to update, which fixes the data loss but leaves the tables under the control of the application, which is a decision to make deliberately rather than inherit.

On maintenance, the project publishes no releases, so there is no version to depend on and the default branch is master rather than main. The last push was on 2026-06-10, which is recent enough that the code is not abandoned, and the Apache-2.0 licence means you can take the pieces you need without a licensing discussion. The documentation also mentions a Swagger configuration, controller advice for the custom exceptions and JSR-303 validation constraints on the entities, and those three are the parts most likely to be worth lifting into a real service.

## Conclusion

Jwt-Spring-Security-JPA is worth reading if you are building a Spring Boot service with JWT and want a concrete reference for the awkward parts, especially the token refresh, the logout blacklist and the separation between the login and the email confirmation endpoints. It is the wrong foundation for a service you intend to run, since the schema is dropped and recreated on each start, the blacklist is in memory only, and the Compose file ships database credentials in the repository. Before you use any of it, read application.properties and mail.properties for the datasource and mail settings, decide whether you want the MySQL setup the documentation describes or the PostgreSQL service that compose.yml actually starts, and treat the in-memory invalidation as a demonstration of the idea rather than as an implementation you can copy into production.

## FAQ

### What does the Jwt-Spring-Security-JPA project demonstrate?

It is a Spring Boot demo of JWT authentication with Spring Security and JPA. It covers registration with admin support, login, multiple device login and logout, an in-memory blacklist of tokens on logout, email verification with expiry and resend, forgot-password reset tokens, refresh tokens and admin protected URLs.

### How do I run the Jwt-Spring-Security-JPA application locally?

Clone the repository, create a database named login_db, edit the datasource credentials in src/main/resources/application.properties and the mail credentials in src/main/resources/mail.properties, then run ./mvnw spring-boot:run. The server starts on port 9004 and the Swagger UI is at http://localhost:9004/swagger-ui.

### Why does the Jwt-Spring-Security-JPA login request need a deviceInfo object?

The deviceInfo block carries a device identifier, a device type and a notification token, and the documentation states that device information is required to enable the multi device login and logout functionality. It is what lets a logout be scoped to a single device session rather than to the whole account.

### Is the MySQL setup in the Jwt-Spring-Security-JPA documentation the one that runs?

Not with the bundled compose.yml, where the MySQL service is commented out and an active PostgreSQL service runs instead, publishing port 5433 for the database named login_db. The datasource properties have to be pointed at whichever engine you decide to use.

## Sources

- [isopropylcyanide/Jwt-Spring-Security-JPA on GitHub](https://github.com/isopropylcyanide/Jwt-Spring-Security-JPA)
- [Issues](https://github.com/isopropylcyanide/Jwt-Spring-Security-JPA/issues)
- [License: Apache-2.0](https://github.com/isopropylcyanide/Jwt-Spring-Security-JPA/blob/master/LICENSE)
- [README](https://github.com/isopropylcyanide/Jwt-Spring-Security-JPA/blob/master/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/isopropylcyanide-jwt-spring-security-jpa
