← Projects

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

Pick an attack

Right after signing up, the user should only be able to complete their profile.

GET / HTTP/1.1Authorization: Bearer <partial token>

Before vulnerable

HTTP/1.1 200 OK

{ "content": [ …usuarios activos… ] }

Now blocked

HTTP/1.1 403 Forbidden

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 PENDING user 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 (access or partial) 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&#39;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&#39;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.