DEV Community

Cover image for Four details I check in any SaaS starter kit, and how mine handles them (Spring Boot 4 + Angular 21)
Mahmoud Farouk
Mahmoud Farouk

Posted on

Four details I check in any SaaS starter kit, and how mine handles them (Spring Boot 4 + Angular 21)

A starter kit's feature list tells you very little. Everyone has "auth, billing, multi-tenancy". What tells you whether a kit is safe to build a business on is a handful of small details: how it stores secrets, what happens when audit logging fails, how it trusts a payment webhook.

I maintain a SaaS starter kit called the Arab Enterprise Kit: Spring Boot 4 on Java 25, Angular 21 with PrimeNG, PostgreSQL, Docker, multi-tenancy with a Hibernate filter, Stripe billing, TOTP two-factor auth, Google OAuth2 and a three-language UI (English, Arabic right-to-left, French). Instead of touring the features, here are four details I would look at in any kit, with the real code behind each.

1. API keys are shown once and stored as hashes

If an API key table leaks, every key in it is compromised, unless you stored only hashes. The kit generates a key from 32 random bytes, stores a SHA-256 hash and a short prefix for display, and returns the raw key exactly once:

byte[] raw = new byte[32];
new SecureRandom().nextBytes(raw);
String rawKey = "ak_" + Base64.getUrlEncoder().withoutPadding().encodeToString(raw);

key.setKeyHash(sha256(rawKey));
key.setKeyPrefix(rawKey.substring(0, Math.min(10, rawKey.length())));
key.setIsActive(true);
key.setExpiresAt(req.resolvedExpiresAt());

ApiKey saved = repository.save(key);
// Return the raw key only once
return ApiKeyResponse.fromWithRaw(saved, rawKey);
Enter fullscreen mode Exit fullscreen mode

Authentication hashes the presented key and looks the hash up, then checks that the key is active and not expired:

return repository.findByKeyHash(sha256(rawKey))
        .filter(ApiKey::getIsActive)
        .filter(k -> k.getExpiresAt() == null || k.getExpiresAt().isAfter(Instant.now()));
Enter fullscreen mode Exit fullscreen mode

Revoking a key flips a flag, so a compromised key stops working immediately without deleting its history. API keys are high-entropy random values, which is why a fast hash is appropriate here; passwords are a different matter and use BCrypt.

2. Audit logging by annotation, and it must never break the request

Audit trails get forgotten when every service method has to remember to write one. In the kit a method opts in with an annotation:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Loggable {
    AuditAction action();
    String entityName();
    String description() default "";
}
Enter fullscreen mode Exit fullscreen mode

An aspect runs after the method returns, reads the user from the security context, the client IP from the request, and the entity id from the returned entity, then saves the log entry:

@AfterReturning(pointcut = "@annotation(...Loggable)", returning = "result")
public void logActivity(JoinPoint joinPoint, Object result) {
    try {
        // username, ip, entityId from the result...
        auditLogService.saveAuditLog(loggable.action(), loggable.entityName(), entityId, username, ip, details);
        if (shouldSendNotification(action)) { /* only CREATE, UPDATE, DELETE */ }
    } catch (Exception e) {
        log.error("Audit Log Failed: {}", e.getMessage());
    }
}
Enter fullscreen mode Exit fullscreen mode

Two decisions are worth copying. Reads do not send notifications, only create, update and delete do. And the whole body is wrapped in a try/catch, so a failure in the audit path is logged but cannot turn a successful business operation into an error.

That second choice is a trade-off, and you should make it consciously. If you need a guarantee that no action happens without an audit record, you want the opposite: fail the operation.

3. A payment webhook is untrusted input until it is verified

Stripe tells your server about payments by calling a public endpoint, so that endpoint cannot use your normal login. The kit excludes it from JWT authentication and relies on Stripe's signature instead:

@PostMapping("/webhook")
public ResponseEntity<String> handleWebhook(
        @RequestBody String payload,
        @RequestHeader("Stripe-Signature") String sigHeader) {

    Event event;
    try {
        event = Webhook.constructEvent(payload, sigHeader, webhookSecret);
    } catch (SignatureVerificationException e) {
        return ResponseEntity.badRequest().body("Invalid signature");
    }

    switch (event.getType()) {
        case "checkout.session.completed"    -> stripeWebhookService.handleCheckoutSessionCompleted(event);
        case "invoice.payment_succeeded"     -> stripeWebhookService.handleInvoicePaymentSucceeded(event);
        case "invoice.payment_failed"        -> stripeWebhookService.handleInvoicePaymentFailed(event);
        case "customer.subscription.deleted" -> stripeWebhookService.handleSubscriptionDeleted(event);
        case "customer.subscription.updated" -> stripeWebhookService.handleSubscriptionUpdated(event);
        default -> log.debug("Unhandled Stripe event type: {}", event.getType());
    }
    return ResponseEntity.ok("Received");
}
Enter fullscreen mode Exit fullscreen mode

The raw body is passed to Webhook.constructEvent exactly as received, because re-serialising it would break the signature. Anything that fails verification gets a 400 and is never parsed into an event. The secret defaults to a placeholder value, so an unconfigured install rejects every call instead of accepting them.

The kit's subscription plans screen: Free, Pro and Enterprise plans with user and usage limits

4. A rate limiter that is honest about what it is

Login endpoints attract brute force. The kit ships a deliberately small limiter for /api/auth/**: 20 requests per IP per 60-second window, then HTTP 429.

RequestCount bucket = ipBuckets.compute(ip, (key, existing) -> {
    if (existing == null || now.isAfter(existing.windowStart().plusSeconds(WINDOW_SECONDS))) {
        return new RequestCount(new AtomicInteger(1), now);
    }
    existing.count().incrementAndGet();
    return existing;
});
Enter fullscreen mode Exit fullscreen mode

Its own comment is the part I like: it says it is an in-memory, zero-dependency implementation, and that for production behind multiple instances you should replace it with Redis-based limiting. Per-account lockout is a separate layer: after 5 failed attempts the account is locked for 15 minutes.

A starter kit does not have to be perfect. It has to tell you where its limits are, so you can decide what to harden before real traffic arrives.

What I would tell my past self

  • Store only hashes of secrets you can show once.
  • Make cross-cutting concerns (audit, notifications) annotations, and decide deliberately whether their failure may break the request.
  • Verify webhook signatures against the raw body, and fail closed when the secret is not configured.
  • Document the limits of every "simple" component in the component itself.

Talk to me

The kit is also on Gumroad. I build SaaS products and take freelance full-stack and backend projects: mahmoud-farouk-portfolio.vercel.app.

What is the first thing you check when you evaluate a starter kit?

Top comments (0)