Every SaaS project starts the same way.
You open a new Spring Boot project and the first three weeks look like this:
- Week 1: JWT auth, refresh tokens, BCrypt passwords
- Week 2: Stripe webhooks, subscription states, idempotency
- Week 3: Rate limiting, global error handling, API docs
You haven't shipped a single feature yet. You've just rebuilt infrastructure you've built five times before.
I got tired of it. So I spent two weeks building it once, properly, with tests, and packaged it as a downloadable starter kit.
Here's everything inside.
What's Included
🔐 Authentication
- JWT login with 15-minute access tokens signed with HMAC-SHA256
- Refresh token rotation — every use issues a new token and revokes the old one
- Replay attack detection — reusing a revoked token revokes your entire session family
- Brute-force protection — 5 failed logins locks the account for 15 minutes
- Spring Security 7 with stateless sessions and lambda DSL
💳 Stripe Subscription Management
- Webhook receiver with HMAC-SHA256 signature validation
- Handles
subscription.created,subscription.updated,subscription.deleted,invoice.payment_failed - Full idempotency guard — duplicate Stripe events are safely ignored
- Mock mode for local dev — no real Stripe account needed to get started
- Subscription states:
ACTIVE,CANCELED,PAST_DUE,TRIALING
🛡️ Production Hardening
- Per-IP rate limiting via Bucket4j — 100 req/min default, 10 req/min on login
-
429 Too Many RequestswithRetry-AfterandX-RateLimit-*headers - Global exception handler — every error returns a structured JSON envelope
- Stack traces never appear in API responses
- BCrypt password hashing at strength 12
📦 Developer Experience
- OpenAPI 3 / Swagger UI with JWT Bearer pre-configured — click Authorize and test immediately
- Docker Compose — one command gets Postgres 16 running
- Flyway migrations V1 to V4 — schema fully version controlled
- MapStruct DTO mapping — entities never leak into API responses
- 18 passing integration tests
The Tech Stack
| Layer | Technology | Version |
|---|---|---|
| Framework | Spring Boot + Spring Security | 4.0.8 / 7.0 |
| Language | Java | 17 |
| Database | PostgreSQL | 16 |
| Migrations | Flyway | 11 |
| JWT | JJWT | 0.12.7 |
| DTO Mapping | MapStruct | 1.6.3 |
| API Docs | springdoc-openapi | 3.1.1 |
| Rate Limiting | Bucket4j | 8.19.0 |
| Container | Docker Compose + Dockerfile | — |
How the Refresh Token Rotation Works
Raw refresh tokens are never stored. Only their SHA-256 hash goes into the database.
Every time a client uses a refresh token, the server marks the old one as revoked and issues a new one. If someone tries to reuse a revoked token — which indicates the token was stolen — the server revokes the entire token family for that user. Every session is terminated immediately.
if (token.isRevoked()) {
// Replay attack detected — revoke entire family
refreshTokenRepository.revokeAllByUserId(token.getUser().getId());
throw new InvalidTokenException(
"Token reused. All sessions revoked for security.");
}
How the Stripe Webhook Idempotency Works
Stripe delivers webhooks at least once. Without protection, a user could end up billed twice or receive two ACTIVE subscriptions.
Every processed event ID is stored in a stripe_events table. Before any business logic runs, the handler checks if the event was already processed.
if (stripeEventRepository.existsByStripeEventId(eventId)) {
log.debug("Duplicate event {} — skipping", eventId);
return; // Already processed, return 200 silently
}
The unique constraint on stripe_event_id in the database is the safety net against race conditions.
The Database Schema
Four Flyway migrations ship out of the box. Hibernate ddl-auto is set to none — Flyway owns the schema, always.
-
V1 —
userstable with BCrypt hash, failed login counter, account lock columns -
V2 —
refresh_tokenswith SHA-256 hash storage and cascade delete -
V3 —
subscriptionswith Stripe customer and subscription ID columns -
V4 —
stripe_eventsas an idempotency log
The 13 API Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/v1/auth/register | None | Register new user |
| POST | /api/v1/auth/login | None | Login, get tokens |
| POST | /api/v1/auth/refresh | None | Rotate refresh token |
| POST | /api/v1/auth/logout | Bearer | Revoke refresh token |
| GET | /api/v1/auth/me | Bearer | Current user profile |
| GET | /api/v1/users/me | Bearer | Get own profile |
| PUT | /api/v1/users/me | Bearer | Update own profile |
| GET | /api/v1/subscriptions/me | Bearer | Subscription status |
| POST | /api/v1/webhooks/stripe | Signature | Stripe event receiver |
| GET | /api/v1/admin/users | ADMIN | List all users |
| GET | /api/v1/admin/users/{id} | ADMIN | Get user by ID |
| PATCH | /api/v1/admin/users/{id}/role | ADMIN | Change user role |
| GET | /actuator/health | None | Health check |
Quick Start After Downloading
1. Purchase and download the zip file from the link at the bottom of this post
2. Unzip it anywhere on your machine
3. Copy .env.example to .env and fill in your values
DB_PASSWORD=your_password
JWT_SECRET=your_base64_secret
STRIPE_WEBHOOK_SECRET=mock
4. Make sure Docker Desktop is running, then start Postgres
docker compose up -d
5. Navigate to the saas-starter folder and run the start script
cd saas-starter
.\run.ps1
The script automatically loads your .env, checks Docker, waits for Postgres to be healthy, and starts the app.
6. Open Swagger UI
http://localhost:8080/swagger-ui.html
All 13 endpoints are ready to test. Click Authorize, paste your JWT token, and every protected endpoint is unlocked.
Extending the Kit
The kit is intentionally focused on the core production foundation. Here are natural next additions you can build on top of it:
-
Email verification — the
enabledcolumn andlock_untilare already in the schema, just needs an email sender bean - Password reset — the token pattern from refresh tokens applies directly
- Redis-backed rate limiting — swap the in-memory map for a Bucket4j Redis store for multi-instance deployments
-
Multi-tenancy — a
tenant_idcolumn plus a Hibernate filter covers most use cases
The architecture is clean enough that adding any of these is straightforward — no spaghetti to untangle first.
Get the Full Kit
The complete source code — migrations, Docker setup, Dockerfile, tests, and README — is available as a zip download here:
👉 Spring Boot 4 SaaS Starter Kit
Starts at $43 for solo developers. One-time purchase — download, own it, extend it however you want.
Top comments (1)
Nice article! I built Webhook Relay because I got tired of losing Stripe events during deploys or downtime. Durable delivery with retries is a core part of it, so a failed subscription event waits in a queue instead of vanishing. Keep the duplicate event check though, since retries mean the same event can land twice.
You can add it for example to your docker-compose.yaml (webhookrelayd container like in this github.com/helixml/helix/blob/main... project)
Same for CI, it can live inside github actions, jenkins etc. All webhooks will just work.