A security-focused authentication service that does cookie-based auth properly — built with Spring Boot 3 and Angular.
Most tutorials store JWTs in localStorage, where any XSS payload can read them. Sentinel keeps tokens
out of JavaScript entirely: a short-lived access token and a rotating refresh token live in
HttpOnly cookies, protected against CSRF with a double-submit token. It demonstrates the patterns that
matter for real authentication — refresh token rotation with reuse detection, brute-force lockout,
Argon2id hashing, security headers and an audit trail — not just "issue a JWT and hope".
- 🍪 Tokens never touch JavaScript — access and refresh tokens are
HttpOnly,Secure,SameSitecookies. An XSS payload cannot exfiltrate them. - 🔁 Refresh token rotation with reuse detection — every refresh issues a brand-new token and revokes the old one. If an already-used (stolen) token is replayed, the entire token family is revoked and the session is killed. This is the headline feature.
- 🛡️ CSRF defense — because auth rides on cookies, every state-changing request must echo a
double-submit
XSRF-TOKEN. - 🔒 Brute-force protection — accounts lock automatically after repeated failed logins.
- 🧂 Argon2id password hashing — modern, memory-hard hashing via a delegating encoder.
- 🧾 Audit trail — every auth event (login, failure, lockout, refresh, reuse detected, logout) is persisted.
- 🪖 Hardened responses — CSP, HSTS,
X-Frame-Options: DENY, referrer policy, stateless sessions.
Refresh tokens are opaque random values; only their SHA-256 hash is stored. Each login starts a token family. Rotation replaces the token on every use; replaying an old one means it was stolen.
sequenceDiagram
participant C as Client (cookies)
participant S as Sentinel
C->>S: POST /login
S-->>C: access (15m) + refresh R1 [family F]
C->>S: POST /refresh (R1)
S->>S: R1 valid -> rotate
S-->>C: new access + refresh R2 [family F], R1 revoked
Note over C,S: attacker stole R1 and replays it
C->>S: POST /refresh (R1)
S->>S: R1 already revoked -> REUSE DETECTED
S->>S: revoke entire family F (R2 included)
S-->>C: 401 + cookies cleared (re-login required)
| Cookie | Flags | Scope | Purpose |
|---|---|---|---|
access_token |
HttpOnly, Secure*, SameSite=Strict |
/ |
Short-lived (15 min) signed JWT |
refresh_token |
HttpOnly, Secure*, SameSite=Strict |
/api/auth |
Long-lived (7 days) opaque, rotating |
XSRF-TOKEN |
readable by JS, SameSite |
/ |
Double-submit CSRF token |
* Secure is enabled via configuration for production (HTTPS); it is off by default for local HTTP.
The SPA reads XSRF-TOKEN and echoes it as the X-XSRF-TOKEN header on every mutating request; the
server rejects the request if they don't match. Login and registration are CSRF-exempt (there is no
ambient session to abuse yet); refresh and logout are not.
| Threat | Mitigation |
|---|---|
| XSS stealing tokens | Tokens are HttpOnly — unreadable from JavaScript |
| CSRF | Double-submit XSRF-TOKEN + SameSite cookies |
| Stolen refresh token | Rotation + reuse detection revokes the whole family |
| Brute-force / credential stuffing | Account lockout after N failed attempts |
| Password database leak | Argon2id (memory-hard) hashing, never plaintext |
| User enumeration | Generic "invalid email or password" + constant-work on unknown users |
| Clickjacking | X-Frame-Options: DENY + CSP frame-ancestors 'none' |
| Token tampering | Access token is an HMAC-signed JWT |
| Concern | Technology |
|---|---|
| Backend | Java 17, Spring Boot 3.3, Spring Security 6 |
| Tokens | JWT (jjwt) access token, opaque rotating refresh token |
| Hashing | Argon2id (BouncyCastle) via DelegatingPasswordEncoder |
| Persistence | PostgreSQL, Spring Data JPA, Flyway |
| Frontend | Angular 21, TypeScript, standalone components, signals |
| Docs | springdoc-openapi (Swagger UI) |
| Testing | JUnit 5, Mockito, Spring Security Test, Testcontainers |
| Tooling | Maven, Docker, GitHub Actions |
docker compose up --build- API: http://localhost:8080
- Swagger UI: http://localhost:8080/swagger-ui.html
cd backend
./mvnw spring-boot:run -Dspring-boot.run.profiles=devcd frontend
npm install
npm startThe app runs at http://localhost:4200 and talks to the API at http://localhost:8080.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /api/auth/register |
— | Create an account, sets auth cookies |
| POST | /api/auth/login |
— | Authenticate, sets auth cookies |
| POST | /api/auth/refresh |
cookie + CSRF | Rotate tokens (detects reuse) |
| POST | /api/auth/logout |
cookie + CSRF | Revoke the session, clears cookies |
| GET | /api/auth/me |
access cookie | Current authenticated user |
| GET | /api/auth/csrf |
— | Bootstrap the XSRF-TOKEN cookie |
cd backend
./mvnw verify- Unit —
RefreshTokenServicerotation/reuse logic andAuthServicelockout rules. - Web — full cookie + CSRF flow through MockMvc (register →
/me, rotation → reuse → 401, lockout). - Integration — the same flow against a real PostgreSQL with Flyway via Testcontainers (skipped automatically without Docker, runs in CI).
sentinel/
├── backend/ # Spring Boot auth service
│ └── src/main/java/com/paulouchoa/sentinel
│ ├── auth/ # controller, service, DTOs, exceptions
│ ├── token/ # JWT, refresh rotation + reuse detection, cookies
│ ├── security/ # cookie auth filter, CSRF filter, entry point
│ ├── user/ # user entity & repository
│ ├── audit/ # auth event trail
│ ├── config/ # security, properties, OpenAPI
│ └── common/ # error handling
├── frontend/ # Angular SPA (login, register, guarded dashboard)
├── docker-compose.yml
└── .github/workflows/ci.yml
- Set
SENTINEL_COOKIE_SECURE=true(HTTPS) and a strongSENTINEL_JWT_SECRET(32+ bytes). - Serve the SPA and API on the same site so
SameSite=Strictcookies flow; consider the__Host-cookie prefix for the access token. - Put the service behind TLS and a rate limiter at the edge in addition to the per-account lockout.
- Email verification and password reset
- Active session management (list / revoke devices)
- TOTP-based two-factor authentication
- Scheduled cleanup of expired refresh tokens