A production-leaning backend starter built with Spring Boot and Kotlin. It ships a complete, stateless JWT authentication flow and role-based user management on top of a clean, layered structure, so you can start building product features instead of wiring auth from scratch.
- Auth — register, login, refresh, logout, logout-all, profile (JWT access + refresh tokens)
- User management — admin-only list (paginated + sortable), get by id, change role, soft delete
- Role-based access control —
USER/ADMIN, enforced with@PreAuthorizeand a stateless JWT filter - Refresh-token rotation — single-use refresh tokens with server-side tracking and stolen-token (replay) detection
- Rate limiting — per-IP token bucket on the auth endpoints as brute-force protection
- Security hardening — BCrypt hashing, stateless sessions, CORS allowlist (no wildcards), fail-fast startup checks on weak/missing config
- Consistent error envelope — one JSON shape for every error, with field-level validation details
- Database-first migrations — Flyway over PostgreSQL
- API docs — OpenAPI 3 + Swagger UI with a JWT "Authorize" button
- Observability — Spring Boot Actuator health/info endpoints (for liveness/readiness probes)
.envsupport — local config viaspring-dotenv
| Language | Kotlin 2.0.21 |
| Framework | Spring Boot 3.4.1 |
| Java | 21 (toolchain) |
| Build | Gradle (Kotlin DSL) |
| Database | PostgreSQL |
| ORM | Spring Data JPA (Hibernate) |
| Migrations | Flyway |
| Security | Spring Security + JJWT 0.12.6 |
| Docs | springdoc-openapi 2.8.0 |
- JDK 21+
- PostgreSQL 12+ (for running the app; the test suite uses in-memory H2 and needs no database)
Option A — Docker (recommended). Brings up PostgreSQL 16 with the default credentials below:
docker compose up -d # starts postgres on localhost:5432
docker compose down # stop
docker compose down -v # stop and wipe the data volumeDefaults: db starter_db, user starter_user, password starter_pass. Override
with DB_NAME / DB_USERNAME / DB_PASSWORD / DB_PORT (compose reads your .env).
Port already in use? If a local PostgreSQL is already bound to
5432, the app may silently connect to it instead of the container. Run the container on a free host port and point the app at it, e.g. setDB_PORT=5544andDB_URL=jdbc:postgresql://localhost:5544/starter_db.
Option B — existing PostgreSQL. Create the database and user manually:
CREATE DATABASE starter_db;
CREATE USER starter_user WITH ENCRYPTED PASSWORD 'starter_pass';
GRANT ALL PRIVILEGES ON DATABASE starter_db TO starter_user;Copy .env.example to .env and fill it in. The important fields:
SPRING_PROFILES_ACTIVE=local
SERVER_PORT=8090
DB_URL=jdbc:postgresql://localhost:5432/starter_db
DB_USERNAME=starter_user
DB_PASSWORD=starter_pass
# Must be at least 32 characters (256-bit) or the app refuses to start.
# Generate one with: openssl rand -base64 48
JWT_SECRET=replace-me-with-a-strong-secret-at-least-32-characters-long
JWT_ACCESS_EXP_MINUTES=15
JWT_REFRESH_EXP_DAYS=7
# Local/dev only: seed an initial admin on startup.
ADMIN_SEED_ENABLED=false
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=admin@pass123
CORS_ALLOWED_ORIGINS=http://localhost:3000.env is git-ignored. For dev/staging/prod, use your platform's secret manager and set the same environment variables.
./gradlew bootRun --args='--spring.profiles.active=local'Flyway applies src/main/resources/db/migration on startup, so the schema is created automatically.
- Swagger UI:
http://localhost:8090/swagger-ui.html - Health:
http://localhost:8090/actuator/health
# Register (returns access + refresh tokens)
curl -X POST http://localhost:8090/api/v1/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"user@example.com","password":"password123"}'
# Call a protected endpoint
curl http://localhost:8090/api/v1/auth/profile \
-H "Authorization: Bearer <ACCESS_TOKEN>"Base path: /api/v1
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /register |
public | Create a USER account, returns tokens |
| POST | /login |
public | Authenticate, returns tokens |
| POST | /refresh |
public | Exchange a refresh token for new tokens |
| POST | /logout |
public | Client-side token discard (stateless) |
| POST | /logout-all |
authenticated | Invalidate every session for the caller (bumps token version) |
| GET | /profile |
authenticated | Current user's profile |
| Method | Path | Description |
|---|---|---|
| GET | / |
Paginated list (page, size, sort=field,dir) |
| GET | /{id} |
Get a user by id |
| PATCH | /{id}/role |
Change a user's role |
| DELETE | /{id} |
Soft-delete a user |
Allowed sort fields: createdAt, updatedAt, email, role. Direction: asc / desc.
- Stateless JWTs. Login/register/refresh issue a short-lived access token and a longer-lived refresh token, both signed with
JWT_SECRET(HS256). - Authority is read from the database, never from the token. The JWT filter loads the user on every request and derives the role from
user.role. A role change or demotion takes effect immediately; a stalerolesclaim in an already-issued token can never grant elevated access. - Token versioning for revocation. Each user has a
tokenVersion.logout-all, a role change, and a soft-delete all increment it, which instantly invalidates any access token issued before the change. - Rotating refresh tokens. Each refresh token carries a
jtiand is tracked server-side inrefresh_tokens. Calling/refreshrevokes the presented token and issues a new pair (single-use). Presenting an already-rotated token is treated as theft: the entire token family is revoked and the user must log in again. Expired records are pruned by a daily scheduled job. - Rate limiting.
/auth/login,/auth/register, and/auth/refreshare throttled per client IP with a token bucket; exhaustion returns429with aRetry-Afterheader. Configure viaRATE_LIMIT_*. - Soft delete. Users are disabled (
disabled_at), not removed. Disabled users fail authentication immediately. - Admin safety guards. An admin cannot change their own role or delete their own account, and the system refuses to demote or delete the last remaining admin (no lock-out).
Every error returns the same envelope:
{
"timestamp": "2026-01-01T12:00:00Z",
"status": 422,
"error": "Unprocessable Entity",
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"path": "/api/v1/auth/register",
"details": [
{ "field": "password", "message": "Password must be between 8 and 30 characters" }
]
}application.yml— shared defaultsapplication-{local,dev,staging,prod}.yml— per-environment overrides
local ships datasource defaults and the admin-seed toggle for convenience; dev/staging/prod require all secrets to be supplied via the environment.
Startup fails fast (refuses to boot) when:
JWT_SECRETis missing or shorter than 32 charactersCORS_ALLOWED_ORIGINSis empty or contains*
./gradlew clean build # compile + run all tests
./gradlew test # tests only
./gradlew bootRun # run (set the profile via --args)The test suite runs entirely on in-memory H2 (test profile) with Flyway disabled, so ./gradlew test is green without any database or Docker.
src/main/kotlin/com/company/starter
├── StarterApplication.kt
├── auth/ # controller, service, DTOs
├── user/ # controller, service, model, repository, seeder
├── security/ # SecurityConfig, JWT filter/service, handlers, SecurityUtils
├── common/ # error envelope + exceptions, pagination
└── config/ # AppProperties, CORS, OpenAPI
This starter intentionally stops at a clean, secure baseline. Common additions:
- Distributed rate limiting (Redis-backed Bucket4j) if you run more than one instance
- Email verification / password reset flows
- Audit logging for auth-sensitive operations
- Metrics & tracing (Micrometer + OpenTelemetry)
Add a license that matches your intended usage (e.g. MIT, Apache-2.0, proprietary).