If you've ever inherited a Keycloak instance and had no idea what a
"realm" actually isolates, or why some clients have secrets and others
don't, this post is the ground floor. We're building one small realm by
hand, in the Admin Console — no automation scripts, no realm-export JSON
to skim past. Just enough clicking to make every concept stick.
By the end you'll have:
- A dedicated realm
- Three roles representing a basic permission tier (
admin,editor,viewer) - A confidential client configured for direct login
- Three users, each mapped to one role
- A working
curlcommand that logs in as each user and shows you their role inside a decoded JWT
Prerequisites
- Docker + Docker Compose
-
curlandpython3(for a one-line JWT decode — no library needed)
1. Start Keycloak
# docker-compose.yml
services:
keycloak:
image: quay.io/keycloak/keycloak:26.7.0
container_name: keycloak-fundamentals
command: start-dev
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- "8080:8080"
docker compose up -d
Give it about 30 seconds, then open http://localhost:8080 and log in
with admin / admin.
start-devis a dev-mode flag — it skips TLS and some production
checks so you can iterate quickly. Never run this flag in production.
2. Create a realm
A realm is Keycloak's top-level tenancy boundary — its own users,
roles, clients, and even its own login theme, fully isolated from every
other realm on the same server. Think of it as a separate mini
directory service you can spin up per environment, per product, or per
customer.
- Top-left realm dropdown → Create realm
- Realm name:
fundamentals - Create
3. Create three realm roles
Roles are how you express "what can this identity do" without hardcoding
usernames into your authorization logic. We're using realm roles
here — visible to every client in the realm, as opposed to client
roles, which are scoped to one specific client. (We'll hit client
roles in a later post when we build a proper RBAC API.)
- Left sidebar → Realm roles → Create role
- Create three, one at a time:
admin,editor,viewer
4. Create a confidential client
- Left sidebar → Clients → Create client
- Client ID:
kc-fundamentals-app→ Next - Client authentication: toggle On
This is the setting that makes a client "confidential" — it gets a
secret, and Keycloak requires that secret before it'll hand out a
token. A public client (toggle off) has no secret at all, because
anything shipped to a browser or mobile app can't actually keep one
secret. We'll build a public client in Part 2.
- Capability config: check Direct access grants, leave Standard flow on if you like (harmless either way for this demo) → Save
- Go to the Credentials tab and copy the Client secret — you'll
paste it into the
curlcommand later
5. Create three users
Repeat this for alice, bob, and carol:
- Users → Add user
- Username:
alice -
First name:
Alice, Last name:Demo
Don't skip this. Keycloak has a built-in "Update Profile" required
action that checks profile completeness dynamically, at login
time — not something you'll see if you inspect the user's stored
data beforehand. An empty first/last name silently blocks login later
with a generic "Account is not fully set up" error that has nothing
to do with the password you set. This one cost me an embarrassing
amount of debugging the first time — save yourself the trouble.
- Create
-
Credentials tab → Set password →
alice→ toggle Temporary: Off → Save
"Temporary" means the user is forced to change it on next login —
fine for humans clicking through a browser flow, but it'll break a
scripted or API-driven login every time, since there's no browser to
serve the "set a new password" screen. Off, for this demo.
-
Role mapping tab → Assign role → pick
admin
Repeat for bob → editor and carol → viewer.
6. One more thing to check: OTP
Some Keycloak versions ship the built-in direct grant authentication
flow with its conditional-OTP branch misconfigured as Required instead
of Conditional. If that's the case on your instance, every login
attempt from a user without an OTP device configured — which is all
three of ours — gets rejected before the password is even checked.
- Authentication (left sidebar) → Flows tab → select direct grant
- Find the OTP-related row at the top level (often labeled something like "Direct Grant - Conditional OTP")
- If it's not already
ConditionalorDisabled, set it to Disabled
7. Log in and inspect the token
This is a Direct Access Grant (also called Resource Owner Password
Credentials, or ROPC) — the client trades a username and password
directly for a token, no browser redirect involved. It's convenient for
this fundamentals demo and for machine-to-machine scripts, but it means
the client sees the raw password, so it's a poor fit for anything
user-facing. (Part 2 covers the flow you actually want for a browser app:
Authorization Code + PKCE.)
curl -s -X POST http://localhost:8080/realms/fundamentals/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "client_id=kc-fundamentals-app" \
-d "client_secret=PASTE_YOUR_SECRET_HERE" \
-d "username=alice" \
-d "password=alice" | python3 -m json.tool
If that returns an access_token, decode its payload to see the role
claim in the flesh:
curl -s -X POST http://localhost:8080/realms/fundamentals/protocol/openid-connect/token \
-d "grant_type=password" -d "client_id=kc-fundamentals-app" \
-d "client_secret=PASTE_YOUR_SECRET_HERE" -d "username=alice" -d "password=alice" \
| python3 -c "
import sys, json, base64
token = json.load(sys.stdin)['access_token']
payload = token.split('.')[1]
payload += '=' * (-len(payload) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(payload)), indent=2))
"
Look for:
"realm_access": {
"roles": ["admin", "default-roles-fundamentals", "offline_access", "uma_authorization"]
}
admin is the one we assigned. The other three are Keycloak's own
housekeeping roles every user gets by default.
Repeat with bob/bob and carol/carol — you should see editor
and viewer respectively.
What actually happened here
-
Realm isolation:
fundamentalsnever sees or touches any other realm's users, clients, or roles — that's the whole point of realms. - Confidential client + Direct Grant: fine for this demo and for trusted server-side/CLI tooling, wrong for a browser SPA (that's Part 2).
- Role claims propagate automatically: once a realm role is assigned to a user, Keycloak embeds it in every token that user gets, with zero extra configuration — this is what your API or gateway will check against later.
Troubleshooting: "Account is not fully set up"
If you hit this, it's one of exactly two causes:
- OTP required in the direct grant flow (see step 6 above)
- Incomplete user profile — missing first/last name (see step 5). This one is genuinely invisible from the outside: it's evaluated live at login, not stored anywhere you can query beforehand.
Check both. They're independent, and fixing only one can still leave you
stuck.
Next up, Part 2: we swap Direct Grant for the flow every real
browser app should use — Authorization Code with PKCE — and build a
vanilla-JS SPA that implements the whole thing by hand, no auth library,
so every step of the exchange is visible.
Top comments (0)