Deep dive · Authentication

Keycloak SSO and identity federation behind an nginx/njs API gateway

Can an application own 100% of its login UI while an identity provider still owns 100% of the credentials? I built the thing to find out, and traced every request to see what actually happens.

· · Keycloak 26 · nginx + njs · MySQL · Source on GitLab →

He who loves Mum, Ei, and Arsenal — and is still chasing the moon🌙

Proudly a HelloCloud ACE candidate.

A note, humbly: I am not an expert, and none of this is a brag. It is all about learning how to learn — and how to unlearn — and cherishing the process somewhere along the way. If you got this far, I hope you learned something too.

Read it, then build it 🛠️

This page is written to be followed along with, not just read. Open the Excalidraw board beside it and work through the steps yourself — every screen I filled in is captured there, in the order I filled it in.

Reading gives you the shape of the thing. Only doing it gives you the understanding. I did not learn any of this by reading, and I do not think anyone does.

Open the first board ↗

Most Keycloak integrations settle for one of two compromises. You either accept the identity provider's hosted pages and theme them, or you build authentication yourself and take on password storage, lockout, rate limiting and audit. This is an attempt at neither: every pixel is the application's, and no credential ever touches application code.

Where I am coming from. I am not a backend or frontend developer. I came at this wanting to understand authentication and authorization from a 360° view — not "which library do I call", but what actually crosses the wire, which component is entitled to decide what, and where a decision becomes irreversible. The frontend and backend in this project exist only to make those questions concrete.

The protocols, briefly

If you are approaching this the way I did, the vocabulary is most of the barrier. Two specifications do the work, and they are often named together as if they were one thing.

OAuth 2.0 is about access. It was designed so an application can act on your behalf without holding your password — you approve it once, and the app receives a token instead of a credential. What OAuth deliberately does not define is who you are: a valid token proves someone approved something, not which human is behind it.

OpenID Connect is a thin identity layer on top of OAuth 2.0. It adds the missing half: an id_token describing the user, an agreed set of claims like sub and email, the openid scope, and standard endpoints for discovery and logout.

The short version: OAuth answers may you, OIDC answers who are you.

MechanismUsed for
Authorization Code + PKCEGoogle / social login. PKCE replaces the client secret a browser cannot keep
kc_idp_hintKeycloak-specific: skip the provider chooser and broker straight to Google
Resource Owner Password Credentialsthe e-mail OTP flow — see the caveat below
Refresh token grantsilent renewal, 30 s before expiry
Client credentials grantnjs authenticating as itself for Admin API calls
Token introspection (RFC 7662)asking Keycloak "is this token still alive?"
RP-initiated logoutending the Keycloak SSO session for social logins

An honest caveat. The Direct Grant is discouraged in modern guidance and dropped in OAuth 2.1, because it normally means an application handling a raw password. It is used here for the opposite reason. It is the one flow that lets the application own the UI while Keycloak still owns the credential. The password field is replaced by a one-time code, and Keycloak generates, hashes, expires and validates it. No password is ever collected by application code. That is a deliberate trade, and worth knowing it is one.

What it looks like

High-level overview of the system
The shape of the system and the three journeys through it.
Components and traffic
The same thing with every component, port and routing rule spelled out.

The decisions that shaped it

I did not want to trust a diagram. For each flow I followed one real request through nginx, njs, Keycloak, the backend and MySQL, and read the logs at every hop. Most of what follows came out of that — including several things I had assumed were true and were not.

Keycloak keeps the credentials; the app keeps the interface

No Keycloak-rendered page appears anywhere. Social login is brokered straight through with kc_idp_hint, so the user goes app → provider → app and never sees an intermediate screen. E-mail OTP runs over the OAuth Direct Grant with a custom Keycloak authenticator, so the code is generated, hashed, expired, rate-limited and validated inside the identity provider.

Cost: ~200 lines of Java as a Keycloak SPI. Bought: the credential handling, lockout and audit trail I would otherwise have had to write and defend.

Single sign-on stays real — hiding the login page does not cost you it

This was the part I most expected to break. It does not: Keycloak still creates a genuine SSO session, held as a cookie on the auth domain, even though the user never saw a Keycloak screen. Sign in once and a second application in the same realm signs you in without a prompt; the session can be ended centrally; kc_idp_hint only skips the chooser, not the protocol. What is avoided is Keycloak's UI, not Keycloak's behaviour.

Application developers never have to learn any of this

That is the property I care about most. What a product team sees is four JSON endpoints and a Bearer token:

POST /auth-api/otp/send      { email }            -> { message: "code_sent" }
POST /auth-api/otp/verify    { email, code }      -> { access_token, ... }
POST /auth-api/refresh       { refresh_token }    -> { access_token, ... }
POST /auth-api/logout        { refresh_token }    -> { message: "logged_out" }

Adding a login button does not require knowing what PKCE is, which client is confidential, that a 401 from Keycloak means the code was mailed, or that azp needs checking. All of that lives in the gateway and in one Keycloak extension — a single place to review, and a single place to change when a rule changes.

Token refresh takes two different paths
A refresh takes a different route depending on how the user signed in — and the application never has to know which.

An API gateway needs real logic, not just routing

This is the conclusion I did not expect at the start. A gateway handles path- and header-based routing out of the box, and for a while I assumed that plus an auth plugin would be the whole job. It is not. The decisions that matter here cannot be expressed as configuration:

None of that is path matching. I do not think this is peculiar to nginx. Every serious gateway ships an extension point for exactly this reason: Lua in Kong and OpenResty, custom authorizers in AWS API Gateway, policies in Apigee. The escape hatch is not an admission that the product is incomplete; it is where your business rules are supposed to live, because no vendor can ship them for you.

The auth API runs inside nginx, not beside it

The OTP flow needs a confidential client secret, which cannot ship to a browser — but a whole container to relay four endpoints seemed like a poor trade. So it runs in njs, JavaScript executing inside nginx itself: no extra service, no extra network hop, and the secret stays server-side.

Cost: njs is a small runtime — no npm, no require, and a fresh VM per request, so nothing can be cached in a variable. That last constraint shaped the code more than anything else.

The backend verifies tokens itself instead of trusting the gateway

The edge could verify a signature and pass identity headers down; that is faster. Instead every /api/* call introspects the token with Keycloak, so the backend learns about logout, revoked sessions and disabled users — things a signature check cannot know, because they happen after signing. The cost is a round-trip per request, softened by a 10-second cache; that cache is itself a deliberate trade, since a revoked token stays usable for up to ten seconds.

Verify at the edge versus introspect per request
I did not pick this blind. Edge verification is the scalable norm and the right default; introspection is what you reach for when the backend must be authoritative about revocation — for a reason, not by default.

Keycloak does not get to see the application database

It is technically possible to point Keycloak at MySQL with user federation and have one store. I decided against it.

Why Keycloak must not connect to MySQL
The reasoning, as its own decision note.

One immutable id links the two databases

Keycloak owns identity, MySQL owns the shop, and the only thing joining them is the Keycloak sub. Not the e-mail, which changes; not the username, which changes. So a user can change their e-mail and keep their order history, because nothing the user can edit is load-bearing.

How Keycloak and the application database relate
Two stores, joined on one immutable id.
Where does each field come from
Every field has exactly one owner. Knowing which is the difference between a sync bug and a system that cannot drift.

Changing an e-mail proves ownership before it commits

In an OTP system the e-mail is the login credential. Writing a new address straight to the account would let a borrowed session repoint it at an attacker's inbox — a temporary compromise becoming permanent. So the new address is parked, a code is sent to it, and only a correct code promotes it. E-mail and username move together, so the old address stops resolving entirely.

Account deactivation lives in the application, not the identity provider

The obvious approach is Keycloak's enabled flag. But social and OTP users have no password, so disabling them there removes the only way they could ever prove ownership again — "pause my account" quietly becomes "locked out forever". Instead the block is a column in MySQL, checked on every request. The user can still authenticate; they simply cannot do anything except reactivate. Authentication intact, authorization suspended.

What only became clear by tracing

The seven flows

Each one follows a single user action from browser to MySQL. The sequence diagrams below are the short version; the repository has the step-by-step walkthrough with the file and function behind every step.

Authentication 1 · Login with an identity provider

Login with IdP flow
First page load, then Google brokered with no Keycloak page, then an authenticated call. Open the full board ↗

Authentication 2 · E-mail OTP login

E-mail OTP flow
Two calls to one Keycloak endpoint. The 401 in the middle is the expected success. Open the full board ↗

Profile 3 · Change e-mail, verified

Verified e-mail change flow
Park the address, mail a code to it, promote only on confirmation. Open the full board ↗

Profile 4 · Change display name

Display name change flow
No verification — a name proves nothing. But the token must be refreshed to see it. Open the full board ↗

Lifecycle 5 · Deactivate

Deactivate flow
The block is a MySQL column, so it applies instantly — even to a token that is still valid. Open the full board ↗

Lifecycle 6 · Reactivate

Reactivate flow
A deactivated user can still authenticate — they simply cannot do anything else. Open the full board ↗

Lifecycle 7 · Delete

Account delete flow
Scrub MySQL first, then remove the identity — sub is the only link between them. Open the full board ↗

The configuration underneath

Keycloak clients, mappers and roles
Two clients, deliberately different: one ships in the browser and can hold no secret, the other never leaves the server and holds all of them.

Running it

Everything is docker-compose, single node. That is a deliberate limit — the point of the project was the auth design, not the platform underneath it.

Locally

docker compose up --build

The first build takes a couple of minutes because Maven compiles the Keycloak extension. Then the shop is on :8090, Keycloak on :8081, and Mailpit on :8025 — which is where the OTP mails land, so you can complete a login without configuring SMTP.

On AWS

An EC2 box, provisioned with Terraform:

cd terraform
terraform init
terraform apply            # t3.medium, Docker preinstalled
terraform output public_ip

Point both subdomains at that IP. Put the TLS certificate and the .env on the host; those are the two things that never live in git. Then copy the code up and start it:

rsync -az --delete \
  --exclude .git --exclude node_modules --exclude dist \
  --exclude .env --exclude gateway/certs \
  ./ ubuntu@$IP:~/app/

ssh ubuntu@$IP
cd ~/app
docker compose -f docker-compose.prod.yml up -d --build

Two things that cost me an outage. Those rsync excludes are not optional. Your local .env is the dev one, and gateway/certs/ usually does not exist locally. With --delete, rsync happily removes the TLS key from the server, and nginx then refuses to start. And compose derives its volume names from the directory name, so deploying from a differently-named folder silently starts with empty databases.

What I would change

Being honest about this is more useful than a feature list.

Still open