DEV Community

Cover image for I built a full IAM system in Spring Boot — here's what broke when I put it on the internet
Tharvesh Muhaideen A for TharvBytes

Posted on

I built a full IAM system in Spring Boot — here's what broke when I put it on the internet

What I built

identityCore is a self-hosted Identity & Access Management (IAM) service — the kind of thing you'd normally reach for Auth0, Keycloak, or Cognito for, built from the ground up in Spring Boot to actually understand how the pieces fit together.

Stack: Spring Boot 3.3.5, Spring Security 6.3.4, Spring Data JPA, PostgreSQL (prod) / H2 (dev), Thymeleaf, HikariCP, BCrypt.

The feature set

  • Three login paths, one identity model: form login, Google (OIDC), GitHub (OAuth2) — all resolving to the same UserEntity
  • RBAC as data, not constants: RoleEntity and PermissionEntity are real JPA entities, editable through an admin UI, not @PreAuthorize("hasRole('ADMIN')") scattered everywhere with no single source of truth
  • REST + MVC in the same app: AuthApiController / UserApiController return a consistent ApiResponse<T> wrapper, while DashboardController / ProfileController / RoleController serve Thymeleaf views
  • One exception handler to rule them all: GlobalExceptionHandler catches ApplicationException, ValidationException, ResourceNotFoundException, AuthenticationException and maps each to the right HTTP status — no leaking stack traces, no inconsistent error shapes across endpoints

Why two OAuth2 user services, not one

This is the part most tutorials skip. Google and GitHub are not the same protocol even though Spring Security's oauth2Login() makes them feel identical:

.oauth2Login(oauth -> oauth
    .userInfoEndpoint(userInfo -> userInfo
        .userService(customOAuth2UserService)     // handles GitHub (plain OAuth2)
        .oidcUserService(customOidcUserService)    // handles Google (OIDC)
    )
)
Enter fullscreen mode Exit fullscreen mode
  • GitHub returns an opaque access_token — no identity claims baked in. You have to call GET /user afterward, and if the user's email is private, that field comes back null. My CustomOAuth2UserService explicitly calls /user/emails to get a real, verified address instead of faking one.
  • Google returns an id_token — a signed JWT with email, email_verified, and name already inside it. CustomOidcUserService reads it directly, no extra network round-trip.

Both funnel into one shared OAuth2UserProvisioner that does the actual find-or-create against the DB — so the "is this a new user" logic exists in exactly one place, regardless of which provider they came from.

The bug that only showed up in production

Everything passed locally. The moment I pointed my own domain at the app through a tunnel (TLS terminated at the edge, plain HTTP to the app on port 8080), OAuth2 login died with redirect_uri_mismatch.

Root cause: Spring Security builds the OAuth2 redirect URI from what it thinks the incoming request's scheme was. Behind a tunnel/reverse proxy, the app only ever sees the internal http://localhost:8080 hop — so it built http://yourdomain.com/login/oauth2/code/google while Google had https://yourdomain.com/... registered. Same code, same config, silently broken by where it was running.

Fix — one line:

server.forward-headers-strategy=framework
Enter fullscreen mode Exit fullscreen mode

This tells Spring to trust the X-Forwarded-Proto / X-Forwarded-Host headers the tunnel already sends, and rebuild URLs with the correct scheme. No proxy config needed on my end — most tunnels send these headers by default.

Lessons

  1. OAuth2 ≠ OIDC. OAuth2 is authorization (what you can access). OIDC adds a purpose-built identity layer (id_token) on top of it for authentication (who you are). Treating them identically works right up until it doesn't.
  2. A working demo and a working deployment are different problems. The exact same SecurityConfig behaves differently depending on what's between the client and your app.
  3. Centralize provisioning. Two auth paths, one user-creation function — not two copies of "does this user exist" logic that can drift apart.

Repo (branch with the OAuth2 work): github.com/Tharvesh2026/identityCore_J25

Happy to go deeper on the RBAC design or the exception-handling setup if there's interest.

Top comments (0)