Motivation: From a Minimal RBAC Start to a Small System, Grown Through Exploration
As a frontend-leaning full-stack developer, microservice authentication was always a thin spot in my understanding. When doing RBAC, the common approach is to deploy the full Spring Security + Nacos + Gateway stack right away – heavy and slow to pick up, where the version and configuration pitfalls the docs gloss over keep getting people stuck. So I wanted a lighter approach: have AI write a minimal RBAC auth loop, read and learn along the way, make sense of the whole picture, then explore on top of it.
At first the goal really was "minimally viable": user registration/login, JWT issuance and verification, RBAC roles and permissions. But once that auth loop worked, the next question came naturally: if a real business service sits behind the gateway, can this auth actually govern it? So I added a CRM customer domain. Then I wanted to see what actually happened on each allow/deny decision, so I added cross-service audit. Finally I wanted a UI to click around in, so I added a Next.js frontend. This article records that evolution – the point isn't "what I wrote," but "what each piece actually does and why it's built this way."
Current stack: Java 17 · Spring Boot 3.2.5 · Spring Cloud 2023.0.3 · Spring Cloud Gateway (WebFlux) · Eureka · Config Server (native) · Spring Data JPA · H2 · Resilience4j · Maven multi-module (7 modules) · Next.js 14 frontend. Three runtime modes (bare jar / Docker / k3s) with zero config changes – the same source runs directly in all three.
Architecture Overview
7 backend services + 1 Next.js frontend:
browser ──► web :3000 (Next.js BFF: /api/* rewritten to gateway, no CORS)
│
▼
gateway-service :4100 PEP: JWT validation + permission decision + audit side-channel + trace ID
│ via Eureka (lb://)
┌───────────┼───────────┬──────────────┐
▼ ▼ ▼ ▼
auth:4101 rbac:4102 customer:4103 audit:4104
register/ PDP CRM+approval append-only audit
login /api/check protected res (gateway async emit / GET query)
config-server :8888 (native config center) eureka-server :8761 (service registry)
| Service | Port | Responsibility |
|---|---|---|
| eureka-server | 8761 | Service registry (@EnableEurekaServer) |
| config-server | 8888 | Config center (@EnableConfigServer, native backend) |
| gateway-service | 4100 | PEP: JWT validation + permission decision + audit side-channel + trace ID |
| auth-service | 4101 | Register / login / /api/me, issues JWT |
| rbac-service | 4102 | PDP: roles / permissions / grants, BFS for effective permissions |
| customer-service | 4103 | CRM customer domain CRUD + delete approval workflow |
| audit-service | 4104 | Cross-service append-only audit log |
| web | 3000 | Next.js BFF dashboard, permission-driven UI |
Auth Core: PEP/PDP + Zero-Dependency JWT
Auth is strictly split into two layers – "allow/deny" and "on what basis" each mind their own business:
- PEP (Policy Enforcement Point) lives in the gateway at
gateway-service/.../filter/AuthGlobalFilter.java(@Order(-1) GlobalFilter). - PDP (Policy Decision Point) lives in
rbac-service, answering "does this user actually have this permission."
AuthGlobalFilter flow:
- Trace ID: read
X-Trace-Id, generate an 8-char hex if absent; write it back to the response and forward it downstream for end-to-end traceability. - Public routes bypass:
/api/login,/api/register,/health,/actuator/**pass through; non-/api/**paths too. - Validate JWT: require
Authorization: Bearer <token>, calljwtUtil.verify; missing token -> 401 (auditauth:missing), invalid -> 401 (auditauth:invalid). - Inject identity: on success, add
X-User: <username>header to the downstream request. - Compute required permission:
mapPermission(path, method)maps the route to a permission; unmapped routes (e.g./api/me,/api/check) only require login. - Call PDP: use
WebClientto callhttp://rbac-service/api/check?user=&permission=, wrapped in circuit breakerrbac-check; onallowedlet it through and emit an ALLOW audit event, otherwise 403 and emit a DENY audit event. - Audit side-channel: after each decision, asynchronously fire-and-forget an event to
audit-service(audit itself is fail-open, never blocks business).
Zero-dependency JWT: no jjwt or nimbus-jose-jwt – HS256 is implemented directly with JDK-native Mac + Base64 + Jackson (auth-service/util/JwtUtil.java and gateway-service/util/JwtUtil.java). Zero dependency is just a side benefit; the real point is to lay bare what JWT is actually computing – how header / payload / base64url are assembled, how HMAC signs, how the signature is verified – so a few dozen lines stop being a black box. The shared secret is injected via app.jwt-secret (auth and gateway share one symmetric key), TTL via app.jwt-ttl defaulting to 86400000 (24h). The gateway's JwtUtil deliberately keeps only the verify path. Password hashing uses PBKDF2WithHmacSHA256 (auth-service/util/PasswordUtil.java): 65536 iterations, 256-bit key, 16-byte salt, output as salt:hash.
Circuit breaker fallback (fail-closed): the gateway's PDP call is wrapped with Resilience4j, instance rbac-check, with:
slidingWindowType: COUNT_BASED,slidingWindowSize: 10minimumNumberOfCalls: 5failureRateThreshold: 50,slowCallRateThreshold: 50slowCallDurationThreshold: 800mswaitDurationInOpenState: 5spermittedNumberOfCallsInHalfOpenState: 3automaticTransitionFromOpenToHalfOpenEnabled: true
The choice is fail-closed: when the breaker is open or the PDP errors, the gateway returns 403 directly – never 200. In security components, deny-by-default is safer than allow-by-default. Breaker status is exposed via Actuator: management.endpoints.web.exposure.include: health,circuitbreakers.
RBAC Model: Roles, Permissions, Grants
Seed data lives in rbac-service/.../service/RbacService.java's seedIfEmpty(), injected at startup by a CommandLineRunner (H2 ddl-auto, clean seed every start):
- Roles:
admin/editor/viewer(three tiers, no inheritance –parentIdis null across the seed). - 11 permissions:
- User/role/permission management:
users:read,users:write,roles:read,roles:write,permissions:read - CRM domain:
customers:read,customers:create,customers:update,customers:delete,customers:approve - Audit:
audit:read
- User/role/permission management:
- Grants:
admin: all 11 (incl.customers:approve,audit:read)editor:users:read,roles:read,permissions:read+customers:read/create/update/delete(7; no approve, no audit:read)viewer:users:read,roles:read,permissions:read,customers:read(4, read-only)
- User -> role:
admin-> admin,user-> editor,viewer-> viewer - Logins:
admin/admin123,user/user123,viewer/viewer123
BFS effective permissions: resolveEffectivePermissions(username) walks the parentId chain in BFS collecting permissions (visited guards against cycles), supporting arbitrary inheritance depth. The current seed doesn't use inheritance, so it effectively reduces to the union of directly-assigned role permissions – the mechanism is there; set parentId in the seed whenever multi-level inheritance is needed. PDP endpoint: GET /api/check?user=&permission=.
Business Domain and Audit: Making Auth Actually Govern Something
Once the auth loop works, adding a business resource shows whether it actually holds.
customer-service (:4103, CRM customer domain): CRUD + search, with gateway routes mapped to customers:read/create/update/delete. The highlight is a delete approval workflow: on DELETE /api/customers/{id}, if the caller has customers:approve (admin) it deletes directly (200); otherwise it returns 202 and creates a pending approval. /api/approvals provides the list + approve / reject (requires customers:approve, approver taken from the X-User header). It also has its own RbacClient calling rbac /api/check to decide direct-delete vs. approval (on error it fails over to the approval flow). H2 ./data/customer, ddl-auto=update.
audit-service (:4104, cross-service append-only audit): the gateway is the sole emitter – after each PEP decision it asynchronously, best-effort, fires an audit event. Writes are allowed only from the gateway via service discovery (with the private header X-Internal-Audit: gateway); the external route /api/audit is GET-only, so writes can't be forged from outside. Queries: GET /api/audit (paginated, filterable by decision / traceId), GET /api/audit/stats (today's overview). Reads require audit:read (admin only). H2 ./data/audit, ddl-auto=update.
With these two added, "authentication" is no longer in the air: customer is a protected business resource, and audit makes every decision traceable.
Frontend BFF: Permission-Driven Dashboard
web (:3000, Next.js 14 + React 18 + TypeScript): BFF pattern – the browser only calls same-origin /api/*, and Next.js rewrites those server-side to the gateway :4100, so there's no CORS. The dashboard has 7 tabs: Roles / Permissions / Users / Customers / Approvals / Audit / Check.
The key part is permission-driven UI: after login it calls PDP /api/check once per permission, and tabs the user lacks show a lock. The frontend never decides permissions itself – it only shows/hides based on PDP results, so permission logic stays centralized in rbac and the UI is just a visualization shell.
Design Tradeoffs: Why These Choices
This combination isn't the only solution, nor necessarily the "optimal" one – it's a deliberate tradeoff under the constraints of minimal dependencies, it actually runs, and each step answers a question:
- PDP stays inside
rbac-service, not wired to OPA / Casbin: first validate that the layering "gateway only enforces, decisions live in one place" actually holds, without taking on a policy engine's complexity up front. - Gateway uses Spring Cloud Gateway, not Kong / APISIX: those are mature production gateways but introduce an extra operational entity. The goal is "stand up PEP with minimal cost inside the Spring ecosystem," so it stays at the application layer.
- JWT implemented natively with the JDK, not
jjwt/nimbus: less about saving a dependency than laying the signing / verification mechanism bare – at the cost of losing a mature library's edge-case coverage and audit assurance. In production, use a mature library; for the demo, this implementation is a way to actually understand JWT. - No Spring Security for auth: same reasoning – don't pull in "version conflicts + config entanglement" prematurely; get the minimal RBAC loop running first.
- customer / audit / web came out of exploration, not scope-padding: each one answers a concrete question – how do you govern a business resource? How do you trace decisions? Is there a UI to click around?
Moving toward production, you'd typically still need: secrets in KMS / Vault instead of hardcoding; PDP split into a standalone service backed by a policy engine; observability (trace / metric / circuit-breaker event alerting); horizontal scaling and statelessness; and stricter auth strength (token revocation, real-time permission-change propagation). This article validates that the approach holds – not that this setup is production-ready.
Implementation Notes
A few things worth knowing before running this stack for real. Wording is aligned with DOCKER.md.
Eureka cold-start 503
Eureka's server response cache defaults to 30000ms and the client fetch interval to 30s; right after a service registers, the gateway doesn't have the instance list yet, so lb://service-name resolves to 503. Fix: set eureka-server's response-cache-update-interval-ms to 3000 (default 30000) and the gateway's registry-fetch-interval-seconds to 3 (default 30); in docker-compose the gateway also sleep 6 as a buffer (comment: "server cache 3s + gateway fetch 3s").
Map-type config injected via -D in containers
eureka.client.serviceUrl is a Map type, and spring.config.import is a bootstrap-phase property. Injecting these via flat env vars (EUREKA_CLIENT_SERVICEURL_DEFAULTZONE / SPRING_CONFIG_IMPORT) is unreliable: the Map won't bind to the defaultZone key, and the config-import URI is resolved during the bootstrap phase where flat env is often not read either, so the client falls back to the localhost default in application.yml and registration fails. The fix is to inject JVM system properties (-D, highest precedence, binds Maps too) via JAVA_TOOL_OPTIONS. This is what actually works in Docker / k3s (see DOCKER.md).
k8s container exits immediately (CrashLoopBackOff)
Each service Dockerfile only has COPY ... app.jar + EXPOSE, no ENTRYPOINT / CMD; docker-compose covers it with command:, but the k8s manifest has no command, so the container has no process to run and exits immediately (Completed = exit 0) -> CrashLoopBackOff. Fix: add command: ["java","-Xmx256m","-jar","app.jar"] to each container in k8s (web is npm run start; the gateway adds a sleep 5 buffer).
k3s image can't be pulled (ContainerCreating stuck)
imagePullPolicy: Never occasionally fails to resolve the local image under OrbStack, leaving kubelet stuck creating the container. Switched to IfNotPresent (use the local image directly, no pull).
initContainer waits for config, not eureka
The manifest's initContainer is named wait-config (waits for config-server:8888); the gateway one waits for config + auth + rbac + customer + audit (five ports); the web one waits for the gateway (wait-gateway). Not eureka.
Three Runtime Modes: One Source, Zero Config Changes
application.yml is never edited; all addresses are injected via env / JAVA_TOOL_OPTIONS.
Bare Jar Mode
make build # mvn clean package -DskipTests -> seven jars
make start # start all 7 services + web in order, wait for readiness
make demo # bash scripts/demo.sh, full flow through gateway :4100
Docker Compose Mode
make docker-up # build + compose up -d --build
make docker-demo # wait for readiness + run demo
make docker-logs # compose logs -f
make docker-stop # compose down (preserves data volumes)
k3s Mode
orb start k8s # OrbStack: enable k3s (or: k3s server)
make k3s-build # actually an alias for docker-build
make k3s-deploy # kubectl apply -f k8s/spring-rbac.yaml
make k3s-demo # port-forward gateway 41000 + web 3000, self-check Pods Running, demo
k8s/spring-rbac.yaml: 8 Deployments + 8 Services, namespace rbac-demo, initContainers wait for dependencies, config injected via env + JAVA_TOOL_OPTIONS. All three modes reuse the same source with no config changes.
Result: From an Auth Loop to a Small Microservice System
Feature checklist:
- User registration/login + JWT issuance and verification (zero-dependency, JDK native)
- RBAC: 3 roles / 11 permissions / 3 grants / BFS effective permissions (inheritance supported, unused in seed)
- Gateway edge auth: PEP + fail-closed circuit breaker + trace ID + audit side-channel
- Centralized PDP decisions:
rbac /api/check - CRM domain: customer-service CRUD + delete approval workflow
- Cross-service audit: audit-service append-only, gateway async emit
- Frontend BFF: Next.js permission-driven dashboard
- Service discovery (Eureka) + config center (Config Server); three runtime modes with zero config changes
It's still a learning / exploration-grade skeleton, far from production – auth strength, secret management, observability, scaling, token revocation all still need work. But it's far more complete than the original "minimal RBAC," and every increment maps to a concrete question.
Source Code Navigation
README.md/README.zh.md– project description, architecture overview, running guidepom.xml– parent POM, declares the Spring Cloud BOM and 7 sub-moduleseureka-server/– service registry (@EnableEurekaServer, response cache tuned to 3000ms)config-server/– config center (@EnableConfigServer, native backend, configs insrc/main/resources/config-repo/, 5 business-service configs)auth-service/src/main/java/com/example/rbac/auth/util/JwtUtil.java– HS256 JWT issuance and verificationauth-service/src/main/java/com/example/rbac/auth/util/PasswordUtil.java– PBKDF2WithHmacSHA256 password hashingauth-service/src/main/java/com/example/rbac/auth/AuthApplication.java– seeded login accountsgateway-service/src/main/java/com/example/rbac/gateway/filter/AuthGlobalFilter.java– gateway PEP: JWT validation + permission decision + audit side-channel + trace IDgateway-service/src/main/java/com/example/rbac/gateway/util/JwtUtil.java– gateway-side JWT parsing (verify-only)gateway-service/src/main/resources/application.yml– 5 routes (lb://) + circuit breaker config + fetch interval 3srbac-service/src/main/java/com/example/rbac/rbac/service/RbacService.java– seed data + BFS effective permissions + checkrbac-service/src/main/java/com/example/rbac/rbac/controller/RbacController.java– PDP endpoint/api/checkcustomer-service/– CRM customer domain + delete approval workflow, includesRbacClientaudit-service/– append-only cross-service audit logweb/– Next.js 14 BFF frontend (next.config.mjsrewrite,lib/permissions.tspermission-driven)k8s/spring-rbac.yaml– k3s / Kubernetes deployment manifest (8 Deployments + 8 Services)docker-compose.yml– Docker Compose orchestration (8 services)Makefile– all make targetsDOCKER.md– Docker Compose and k3s running manual (with detailed pitfall notes)ARCHITECTURE.md– in-depth architecture analysis and design decisions
Full project on GitHub: https://github.com/erishen/spring-rbac