<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rakusasu Stack</title>
    <description>The latest articles on DEV Community by Rakusasu Stack (@rakusasu_stack).</description>
    <link>https://dev.to/rakusasu_stack</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4126548%2Fe8aa1327-febf-4c01-8959-ba80745cdaba.jpg</url>
      <title>DEV Community: Rakusasu Stack</title>
      <link>https://dev.to/rakusasu_stack</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rakusasu_stack"/>
    <language>en</language>
    <item>
      <title>I Built a Spring Boot 4 SaaS Starter Kit So You Never Have to Write Auth Boilerplate Again</title>
      <dc:creator>Rakusasu Stack</dc:creator>
      <pubDate>Tue, 15 Sep 2026 15:47:52 +0000</pubDate>
      <link>https://dev.to/rakusasu_stack/i-built-a-spring-boot-4-saas-starter-kit-so-you-never-have-to-write-auth-boilerplate-again-4noc</link>
      <guid>https://dev.to/rakusasu_stack/i-built-a-spring-boot-4-saas-starter-kit-so-you-never-have-to-write-auth-boilerplate-again-4noc</guid>
      <description>&lt;p&gt;Every SaaS project starts the same way.&lt;/p&gt;

&lt;p&gt;You open a new Spring Boot project and the first three weeks look like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Week 1:&lt;/strong&gt; JWT auth, refresh tokens, BCrypt passwords&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 2:&lt;/strong&gt; Stripe webhooks, subscription states, idempotency&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 3:&lt;/strong&gt; Rate limiting, global error handling, API docs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You haven't shipped a single feature yet. You've just rebuilt infrastructure you've built five times before.&lt;/p&gt;

&lt;p&gt;I got tired of it. So I spent two weeks building it once, properly, with tests, and packaged it as a downloadable starter kit.&lt;/p&gt;

&lt;p&gt;Here's everything inside.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Included
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;🔐 Authentication&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JWT login with 15-minute access tokens signed with HMAC-SHA256&lt;/li&gt;
&lt;li&gt;Refresh token rotation — every use issues a new token and revokes the old one&lt;/li&gt;
&lt;li&gt;Replay attack detection — reusing a revoked token revokes your entire session family&lt;/li&gt;
&lt;li&gt;Brute-force protection — 5 failed logins locks the account for 15 minutes&lt;/li&gt;
&lt;li&gt;Spring Security 7 with stateless sessions and lambda DSL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;💳 Stripe Subscription Management&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Webhook receiver with HMAC-SHA256 signature validation&lt;/li&gt;
&lt;li&gt;Handles &lt;code&gt;subscription.created&lt;/code&gt;, &lt;code&gt;subscription.updated&lt;/code&gt;, &lt;code&gt;subscription.deleted&lt;/code&gt;, &lt;code&gt;invoice.payment_failed&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Full idempotency guard — duplicate Stripe events are safely ignored&lt;/li&gt;
&lt;li&gt;Mock mode for local dev — no real Stripe account needed to get started&lt;/li&gt;
&lt;li&gt;Subscription states: &lt;code&gt;ACTIVE&lt;/code&gt;, &lt;code&gt;CANCELED&lt;/code&gt;, &lt;code&gt;PAST_DUE&lt;/code&gt;, &lt;code&gt;TRIALING&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;🛡️ Production Hardening&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Per-IP rate limiting via Bucket4j — 100 req/min default, 10 req/min on login&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;429 Too Many Requests&lt;/code&gt; with &lt;code&gt;Retry-After&lt;/code&gt; and &lt;code&gt;X-RateLimit-*&lt;/code&gt; headers&lt;/li&gt;
&lt;li&gt;Global exception handler — every error returns a structured JSON envelope&lt;/li&gt;
&lt;li&gt;Stack traces never appear in API responses&lt;/li&gt;
&lt;li&gt;BCrypt password hashing at strength 12&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;📦 Developer Experience&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OpenAPI 3 / Swagger UI with JWT Bearer pre-configured — click Authorize and test immediately&lt;/li&gt;
&lt;li&gt;Docker Compose — one command gets Postgres 16 running&lt;/li&gt;
&lt;li&gt;Flyway migrations V1 to V4 — schema fully version controlled&lt;/li&gt;
&lt;li&gt;MapStruct DTO mapping — entities never leak into API responses&lt;/li&gt;
&lt;li&gt;18 passing integration tests&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Tech Stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;Spring Boot + Spring Security&lt;/td&gt;
&lt;td&gt;4.0.8 / 7.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migrations&lt;/td&gt;
&lt;td&gt;Flyway&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JWT&lt;/td&gt;
&lt;td&gt;JJWT&lt;/td&gt;
&lt;td&gt;0.12.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DTO Mapping&lt;/td&gt;
&lt;td&gt;MapStruct&lt;/td&gt;
&lt;td&gt;1.6.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Docs&lt;/td&gt;
&lt;td&gt;springdoc-openapi&lt;/td&gt;
&lt;td&gt;3.1.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rate Limiting&lt;/td&gt;
&lt;td&gt;Bucket4j&lt;/td&gt;
&lt;td&gt;8.19.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Container&lt;/td&gt;
&lt;td&gt;Docker Compose + Dockerfile&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  How the Refresh Token Rotation Works
&lt;/h2&gt;

&lt;p&gt;Raw refresh tokens are never stored. Only their SHA-256 hash goes into the database.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;entire token family&lt;/strong&gt; for that user. Every session is terminated immediately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isRevoked&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Replay attack detected — revoke entire family&lt;/span&gt;
    &lt;span class="n"&gt;refreshTokenRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;revokeAllByUserId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUser&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidTokenException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"Token reused. All sessions revoked for security."&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  How the Stripe Webhook Idempotency Works
&lt;/h2&gt;

&lt;p&gt;Stripe delivers webhooks at least once. Without protection, a user could end up billed twice or receive two ACTIVE subscriptions.&lt;/p&gt;

&lt;p&gt;Every processed event ID is stored in a &lt;code&gt;stripe_events&lt;/code&gt; table. Before any business logic runs, the handler checks if the event was already processed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stripeEventRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;existsByStripeEventId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eventId&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Duplicate event {} — skipping"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;eventId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Already processed, return 200 silently&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unique constraint on &lt;code&gt;stripe_event_id&lt;/code&gt; in the database is the safety net against race conditions.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Database Schema
&lt;/h2&gt;

&lt;p&gt;Four Flyway migrations ship out of the box. Hibernate &lt;code&gt;ddl-auto&lt;/code&gt; is set to &lt;code&gt;none&lt;/code&gt; — Flyway owns the schema, always.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;V1&lt;/strong&gt; — &lt;code&gt;users&lt;/code&gt; table with BCrypt hash, failed login counter, account lock columns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;V2&lt;/strong&gt; — &lt;code&gt;refresh_tokens&lt;/code&gt; with SHA-256 hash storage and cascade delete&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;V3&lt;/strong&gt; — &lt;code&gt;subscriptions&lt;/code&gt; with Stripe customer and subscription ID columns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;V4&lt;/strong&gt; — &lt;code&gt;stripe_events&lt;/code&gt; as an idempotency log&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The 13 API Endpoints
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Auth&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;/api/v1/auth/register&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Register new user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;/api/v1/auth/login&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Login, get tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;/api/v1/auth/refresh&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Rotate refresh token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;/api/v1/auth/logout&lt;/td&gt;
&lt;td&gt;Bearer&lt;/td&gt;
&lt;td&gt;Revoke refresh token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;/api/v1/auth/me&lt;/td&gt;
&lt;td&gt;Bearer&lt;/td&gt;
&lt;td&gt;Current user profile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;/api/v1/users/me&lt;/td&gt;
&lt;td&gt;Bearer&lt;/td&gt;
&lt;td&gt;Get own profile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PUT&lt;/td&gt;
&lt;td&gt;/api/v1/users/me&lt;/td&gt;
&lt;td&gt;Bearer&lt;/td&gt;
&lt;td&gt;Update own profile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;/api/v1/subscriptions/me&lt;/td&gt;
&lt;td&gt;Bearer&lt;/td&gt;
&lt;td&gt;Subscription status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;/api/v1/webhooks/stripe&lt;/td&gt;
&lt;td&gt;Signature&lt;/td&gt;
&lt;td&gt;Stripe event receiver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;/api/v1/admin/users&lt;/td&gt;
&lt;td&gt;ADMIN&lt;/td&gt;
&lt;td&gt;List all users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;/api/v1/admin/users/{id}&lt;/td&gt;
&lt;td&gt;ADMIN&lt;/td&gt;
&lt;td&gt;Get user by ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PATCH&lt;/td&gt;
&lt;td&gt;/api/v1/admin/users/{id}/role&lt;/td&gt;
&lt;td&gt;ADMIN&lt;/td&gt;
&lt;td&gt;Change user role&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;/actuator/health&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Health check&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Quick Start After Downloading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1.&lt;/strong&gt; Purchase and download the zip file from the link at the bottom of this post&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2.&lt;/strong&gt; Unzip it anywhere on your machine&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3.&lt;/strong&gt; Copy &lt;code&gt;.env.example&lt;/code&gt; to &lt;code&gt;.env&lt;/code&gt; and fill in your values&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;DB_PASSWORD&lt;/span&gt;=&lt;span class="n"&gt;your_password&lt;/span&gt;
&lt;span class="n"&gt;JWT_SECRET&lt;/span&gt;=&lt;span class="n"&gt;your_base64_secret&lt;/span&gt;
&lt;span class="n"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;=&lt;span class="n"&gt;mock&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4.&lt;/strong&gt; Make sure Docker Desktop is running, then start Postgres&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5.&lt;/strong&gt; Navigate to the &lt;code&gt;saas-starter&lt;/code&gt; folder and run the start script&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;cd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;saas-starter&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;\run.ps1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script automatically loads your &lt;code&gt;.env&lt;/code&gt;, checks Docker, waits for Postgres to be healthy, and starts the app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6.&lt;/strong&gt; Open Swagger UI&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://localhost:8080/swagger-ui.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All 13 endpoints are ready to test. Click &lt;strong&gt;Authorize&lt;/strong&gt;, paste your JWT token, and every protected endpoint is unlocked.&lt;/p&gt;




&lt;h2&gt;
  
  
  Extending the Kit
&lt;/h2&gt;

&lt;p&gt;The kit is intentionally focused on the core production foundation. Here are natural next additions you can build on top of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Email verification&lt;/strong&gt; — the &lt;code&gt;enabled&lt;/code&gt; column and &lt;code&gt;lock_until&lt;/code&gt; are already in the schema, just needs an email sender bean&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Password reset&lt;/strong&gt; — the token pattern from refresh tokens applies directly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis-backed rate limiting&lt;/strong&gt; — swap the in-memory map for a Bucket4j Redis store for multi-instance deployments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tenancy&lt;/strong&gt; — a &lt;code&gt;tenant_id&lt;/code&gt; column plus a Hibernate filter covers most use cases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The architecture is clean enough that adding any of these is straightforward — no spaghetti to untangle first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Get the Full Kit
&lt;/h2&gt;

&lt;p&gt;The complete source code — migrations, Docker setup, Dockerfile, tests, and README — is available as a &lt;strong&gt;zip download&lt;/strong&gt; here:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://rakusasustack.lemonsqueezy.com/checkout/buy/2c2cba53-0e30-4bdc-88bb-6552b8d502a0" rel="noopener noreferrer"&gt;Spring Boot 4 SaaS Starter Kit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Starts at &lt;strong&gt;$43&lt;/strong&gt; for solo developers. One-time purchase — download, own it, extend it however you want.&lt;/p&gt;




</description>
      <category>java</category>
      <category>springboot</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
