CRUD-Test API de Usuarios con Autenticación JWT
My first solo backend project, a user CRUD with MySQL and JWT. Revisiting it later, I found the sign-up token unlocked the whole API; I fixed it and covered every flaw with tests.
- Stack
- Java · Spring · MySQL · Flyway · JWT
- Status
- Completed
- Year
- 2025
- Code
- GitHub ↗DeepWiki ↗
Pick an attack
Right after signing up, the user should only be able to complete their profile.
Before vulnerable
HTTP/1.1 200 OK
{ "content": [ …usuarios activos… ] }
Now blocked
HTTP/1.1 403 Forbidden
The account is still PENDING.
Before vulnerable
HTTP/1.1 200 OK
{ "token": "eyJhbGciOiJIUzI1NiJ9…" }
Now blocked
HTTP/1.1 401 Unauthorized
"Credenciales inválidas o cuenta no activa"
Alice, logged in, swaps the id in the URL for Bob's.
Before vulnerable
HTTP/1.1 204 No Content
Now blocked
HTTP/1.1 403 Forbidden
"No puedes modificar la cuenta de otro usuario"
Where it started
It was my first solo backend project, written without AI help. I wanted to prove three things: connecting a real database (MySQL, with the schema versioned in Flyway), authenticating with JWT, and building a user CRUD on top. The business logic is simple on purpose: the goal was to learn how those pieces fit together.
Two-phase sign-up
A new user signs up with an email and password and lands in the PENDING state. The response carries a 15-minute partial token that is good for one thing only: completing the profile. Once that's done, the account moves to ACTIVE and can log in to get a one-hour access token.
States don't change freely: a five-state machine (PENDING, ACTIVE, INACTIVE, SUSPENDED, DELETED) defines which transitions are allowed, and a deleted account never comes back.
What I found coming back to it
Later on, while preparing this case study, I reviewed it with AI assistance, comparing what the API promised with what it actually did. With more experience, the mistakes of a first project stood out:
- The partial token and the access token were the same JWT: same signature, same issuer, same user. Only the expiry differed. With the sign-up token you could use the whole API for 15 minutes.
- A
PENDINGuser could log in and get an access token without completing anything. - Any authenticated user could deactivate or rename someone else's account by changing the id.
- The secret used to sign tokens was written in the code, in a public repository.
- On a fresh database, sign-up always failed: no migration created the default plan assigned to every user.
How I fixed it
- Every token now carries its type (
accessorpartial) in a claim, and checking it is part of validating the token. The security filter only accepts access tokens; with a partial token, the request stays anonymous. - Only an active account authenticates: neither login nor a previously issued token works if the account is pending or deactivated.
- Renaming or deactivating an account requires being its owner or an admin.
- The secret comes from an environment variable, and the app won't start without it.
- Along the way, token expiry is computed in UTC. It used to assume the -05:00 zone, and on a server in another zone tokens lasted hours longer.
How I know it's fixed
I wrote an integration test that walks the whole flow against real MySQL (sign-up, activation, login and deactivation), with one case per flaw. To check the tests really catch the problems, I also ran them against the old code: 5 of 6 failed. With the fixes, they all pass.
What was already right
- Consuming the partial token uses optimistic locking (
@Version): two simultaneous requests with the same token can't activate the account twice. - A scheduled job cleans up used or expired tokens every month.
- The schema evolved through 17 Flyway migrations.
What's still pending
The error handler still turns any unexpected exception into a 404, and Hibernate still runs ddl-auto=update alongside Flyway. They're next on the list.