The setup here is a small bookstore: a React app on port 5173, a Go API on 8181 that serves a list of books, and Keycloak on 8180 holding the users. Alice has the reader role and gets the books. Bob can sign in, but the API refuses him. Nobody’s password ever reaches the React app or the Go API.
The code is on GitHub: huseyinbabal/keycloak-react-go.
Versions used: Keycloak 26.8.1, Postgres 18.6, keycloak-js 26.2.4, React 19.3, Vite 8.3, Go 1.27.3, github.com/coreos/go-oidc/v3 v3.21.0.
What hosted auth costs
Before running your own, it’s worth knowing the alternative’s price. These are the list prices on each vendor’s pricing page, read on 2026-10-25.
| Service | Free up to | First paid tier | Enterprise SSO |
|---|---|---|---|
| Clerk | 50,000 monthly retained users per app | Pro $25/mo, then $0.02 per user from 50,001 to 100k, $0.018 up to 1M | 1 connection included, each extra $75/mo |
| Supabase Auth | 50,000 MAU | Pro from $25/mo with 100,000 MAU, then $0.00325 per MAU | SAML: 50 MAU included, then $0.015 per MAU, not on Free |
| Auth0 | 25,000 MAU | B2C Essentials $700/mo at 10k MAU, Professional $1,600/mo at 10k | +$100/mo per connection |
| Firebase Auth | 50,000 MAU | Identity Platform pricing above that | SAML and OIDC free for the first 50 MAU only |
The free tiers cover a lot of side projects. The cost shows up in two places: the per-user line once you grow, and single sign-on as soon as a business customer asks for it. Keycloak has no per-user fee. You pay with your own time instead: running it, upgrading it, backing up its database.
The four ideas the code depends on
Authentication vs authorization. Keycloak checks who the user is and gives the app an access token for the API. That token is issued to the client, so the API decides whether this client, acting for this user, may read books: the token has to be meant for it (aud: api), and the user it was issued for has to have the reader role, which Keycloak copied into the token. The check uses the user’s realm roles, not OAuth scopes.
OAuth 2.0 and OpenID Connect. OAuth 2.0 is how an app gets an access token for an API. OpenID Connect (OIDC) is a layer on OAuth 2.0 that adds an ID token describing the signed-in user. keycloak-js and go-oidc both speak OIDC.
Authorization code flow with PKCE. The browser is sent to Keycloak’s login page, the user types the password there, and Keycloak redirects back with a one-time code that the app exchanges for tokens. A single-page app can’t keep a client secret (its bundle is public), so it’s registered as a public client and uses PKCE (RFC 7636):
| Step | What the React app sends |
|---|---|
| before redirecting to login | code_challenge = base64url(SHA-256(random code_verifier)), code_challenge_method=S256 |
| exchanging the code | the original code_verifier |
| Keycloak checks | SHA-256(verifier) equals the challenge from step 1 |
Someone who intercepts the code doesn’t have the verifier, so the code is worthless to them.
Three tokens. The ID token describes the user, the access token is what the API checks, and the refresh token gets a new access token. Keycloak’s default access token lifespan is 5 minutes. The access token is a JWT, and the API reads it offline: it verifies the signature with Keycloak’s public keys from the realm’s JWKS endpoint, so no per-request call to Keycloak is needed.
The claims this API cares about, as the React app decodes them from alice’s access token (keycloak.tokenParsed, with exp turned into a local time):
{
"iss": "http://localhost:8180/realms/bookstore",
"aud": ["api", "account"],
"roles": ["default-roles-bookstore", "reader", "offline_access", "uma_authorization"],
"exp": "8:52:32 PM"
}output
roles here is realm_access.roles from the token. api in aud comes from the audience mapper set up below; account and the three extra roles are Keycloak’s defaults for every user in a realm.
One compose file
name: keycloak-demo
services:
postgres:
image: postgres:18.6
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak
volumes:
- pgdata:/var/lib/postgresql
keycloak:
image: quay.io/keycloak/keycloak:26.8.1
command: start-dev --import-realm
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak
KC_HOSTNAME: http://localhost:8180
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
volumes:
- ./realm:/opt/keycloak/data/import
ports:
- "8180:8080"
depends_on:
- postgres
api:
build: ./api
environment:
ISSUER: http://localhost:8180/realms/bookstore
JWKS_URL: http://keycloak:8080/realms/bookstore/protocol/openid-connect/certs
AUDIENCE: api
WEB_ORIGIN: http://localhost:5173
ports:
- "8181:8181"
web:
image: node:26-slim
working_dir: /app
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
volumes:
- ./web:/app
- /app/node_modules
ports:
- "5173:5173"
volumes:
pgdata:compose.yaml
Two details are easy to get wrong:
ISSUERandJWKS_URLpoint at different hosts on purpose. The browser reaches Keycloak atlocalhost:8180, so that’s theissinside every token, and the API must expect exactly that string. The API container fetches keys over the compose network atkeycloak:8080.KC_HOSTNAMEpins the issuer so it doesn’t change with the address a request came in on.start-devis development mode: plain HTTP, relaxed hostname checks, a local cache.startis the production mode and refuses to run until it has a hostname and TLS configured. The admin user and password above are for a local machine only.
Realm, client, mapper, role, users
Every step below can be clicked in the admin console. Here it’s done with kcadm.sh, which ships inside the Keycloak image (run it with docker compose exec keycloak /opt/keycloak/bin/kcadm.sh …), and with the Admin REST API that both of them use.
# log in once
kcadm.sh config credentials --server http://localhost:8080 --realm master --user admin --password admin
TOKEN=$(curl -s -d client_id=admin-cli -d username=admin -d password=admin -d grant_type=password \
http://localhost:8180/realms/master/protocol/openid-connect/token | jq -r .access_token)
Realm. master is for administering Keycloak itself, so the app gets its own realm.
kcadm.sh create realms -s realm=bookstore -s enabled=true
curl -X POST localhost:8180/admin/realms -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"realm":"bookstore","enabled":true}' # 201
Client web. Public (no client authentication), only the standard flow (authorization code), PKCE required with S256, and the redirect URIs and web origins limited to the app’s address. A redirect URI that isn’t on the list is refused, so a look-alike site can’t collect codes.
kcadm.sh create clients -r bookstore -s clientId=web -s publicClient=true -s standardFlowEnabled=true \
-s directAccessGrantsEnabled=false -s 'redirectUris=["http://localhost:5173/*"]' \
-s 'webOrigins=["http://localhost:5173"]' \
-s 'attributes={"pkce.code.challenge.method":"S256","post.logout.redirect.uris":"http://localhost:5173/*"}'
Audience mapper. The Go API only accepts tokens whose aud contains api. An audience protocol mapper on the web client adds it to every access token:
kcadm.sh create clients/$CID/protocol-mappers/models -r bookstore -s name=api-audience -s protocol=openid-connect \
-s protocolMapper=oidc-audience-mapper \
-s 'config={"included.custom.audience":"api","access.token.claim":"true","id.token.claim":"false"}'
Role and users.
kcadm.sh create roles -r bookstore -s name=reader
kcadm.sh create users -r bookstore -s username=alice -s enabled=true -s email=alice@example.com \
-s emailVerified=true -s firstName=Alice -s lastName=Doe
kcadm.sh set-password -r bookstore --username alice --new-password <password>
kcadm.sh add-roles -r bookstore --uusername alice --rolename reader
# bob: same create + set-password, no add-roles
The REST equivalents are one POST each: /admin/realms/bookstore/clients, …/clients/$CID/protocol-mappers/models, …/roles, …/users (with a credentials array), and …/users/$USER_ID/role-mappings/realm (204).
Keep it. A realm export writes all of the above, users included, to one JSON file. With --import-realm in the compose command, a fresh docker compose up brings the realm back.
docker compose exec keycloak /opt/keycloak/bin/kc.sh export --realm bookstore \
--file /tmp/bookstore-realm.json --users same_file
docker compose cp keycloak:/tmp/bookstore-realm.json realm/
jq -c '{realm, users: [.users[].username]}' realm/bookstore-realm.json
{"realm":"bookstore","users":["alice","bob"]}output
React: keycloak-js
import Keycloak from 'keycloak-js'
export const keycloak = new Keycloak({
url: 'http://localhost:8180',
realm: 'bookstore',
clientId: 'web',
})web/src/keycloak.js
import { createRoot } from 'react-dom/client'
import { keycloak } from './keycloak'
import App from './App'
await keycloak.init({ onLoad: 'check-sso', pkceMethod: 'S256' })
createRoot(document.getElementById('root')).render(<App />)web/src/main.jsx
check-sso asks Keycloak whether a session already exists and renders the signed-out page if not. login-required would redirect straight to the login page instead. pkceMethod: 'S256' makes keycloak-js generate the verifier and challenge from the table above.
The only part of the app that touches tokens:
async function loadBooks() {
// refresh the access token if it expires in the next 30 seconds
await keycloak.updateToken(30)
const res = await fetch('http://localhost:8181/api/books', {
headers: { Authorization: `Bearer ${keycloak.token}` },
})
if (!res.ok) {
setError(`${res.status} ${await res.text()}`)
return
}
setBooks(await res.json())
}web/src/App.jsx
With a 5 minute access token, a tab left open for ten minutes would send an expired token without the updateToken call. Sign in and sign out are keycloak.login() and keycloak.logout().
Go: go-oidc
type Claims struct {
Username string `json:"preferred_username"`
RealmAccess struct {
Roles []string `json:"roles"`
} `json:"realm_access"`
}
func main() {
keys := oidc.NewRemoteKeySet(context.Background(), os.Getenv("JWKS_URL"))
verifier := oidc.NewVerifier(os.Getenv("ISSUER"), keys, &oidc.Config{ClientID: os.Getenv("AUDIENCE")})
mux := http.NewServeMux()
mux.Handle("GET /api/books", requireRole(verifier, "reader", listBooks))
log.Fatal(http.ListenAndServe(":8181", cors(os.Getenv("WEB_ORIGIN"), mux)))
}
func requireRole(v *oidc.IDTokenVerifier, role string, next http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok {
http.Error(w, "missing token", http.StatusUnauthorized)
return
}
// checks signature, issuer, audience and expiry
token, err := v.Verify(r.Context(), raw)
if err != nil {
http.Error(w, "invalid token: "+err.Error(), http.StatusUnauthorized)
return
}
var c Claims
if err := token.Claims(&c); err != nil {
http.Error(w, "bad claims", http.StatusUnauthorized)
return
}
if !slices.Contains(c.RealmAccess.Roles, role) {
http.Error(w, c.Username+" does not have the "+role+" role", http.StatusForbidden)
return
}
next(w, r)
})
}api/main.go
NewRemoteKeySet caches Keycloak’s keys and fetches them again when a token arrives with a key ID it hasn’t seen, which covers key rotation. The type is called IDTokenVerifier, but with ClientID set to api it checks the aud of the access token, which is what the mapper added.
The status codes follow one rule: 401 when the API can’t tell who is calling, 403 when it can and the answer is no.
| Request | Status | Body |
|---|---|---|
no Authorization header | 401 | missing token |
Bearer made.up.token | 401 | invalid token: oidc: malformed jwt: illegal base64 data at input byte 4 |
| bob’s valid token | 403 | bob does not have the reader role |
| alice’s valid token | 200 | the three books |
curl -i localhost:8181/api/books
curl -i -H "Authorization: Bearer made.up.token" localhost:8181/api/books
HTTP/1.1 401 Unauthorized
Content-Type: text/plain; charset=utf-8
missing token
HTTP/1.1 401 Unauthorized
Content-Type: text/plain; charset=utf-8
invalid token: oidc: malformed jwt: illegal base64 data at input byte 4output
An expired token, a token from another realm (wrong iss) or one minted for another client without the mapper (no api in aud) all end in the same 401 branch, each with go-oidc’s reason in the body.
Before production
A single Keycloak container is enough on a laptop and nowhere else: if it stops, nobody can sign in. Keycloak documents two production layouts. In the first, one cluster has nodes in several availability zones. In the second, there are at least two clusters, each in its own zone, behind a load balancer. The sizing chapter of that documentation turns Keycloak’s load test results into rules of thumb:
| Load | Plan for |
|---|---|
| password logins | 1 vCPU per 15 logins/s |
| refresh token or client credentials grants | 1 vCPU per 120 requests/s |
| memory | about 1250 MB per pod with 10,000 cached sessions |
| headroom | 150% extra CPU for spikes |
Those ratios were measured at up to 300 password logins and 2,000 client credentials grants per second, so treat them as a starting point above that. Use start with a real hostname and TLS, keep the Postgres database backed up, and keep the realm export in version control.
What this setup doesn’t use yet
| Feature | Where in the admin console |
|---|---|
| Users from Active Directory or LDAP | User federation |
| Sign in with Google, GitHub, Microsoft, or any OIDC/SAML provider | Identity providers |
| One-time codes or security keys as a second step | Authentication flows |
| Self-registration, forgot password, passkeys | Realm settings → Login |
| Users grouped by customer company | Organizations |
| Your own login page look | Themes |
Run it yourself
git clone https://github.com/huseyinbabal/keycloak-react-go && cd keycloak-react-go
docker compose up -d
# admin console: http://localhost:8180 (admin / admin, local only)
# app: http://localhost:5173 (alice has reader, bob doesn't)
curl -i localhost:8181/api/books # 401 missing token
Sources
- Keycloak: Configuring Keycloak (start-dev vs start)
- Keycloak: Importing and exporting realms
- Keycloak: Admin CLI
- Keycloak: JavaScript adapter (keycloak-js)
- Keycloak: High availability guide
- Keycloak: Concepts for sizing CPU and memory resources
- go-oidc package documentation
- RFC 7636: Proof Key for Code Exchange
- OpenID Connect Core 1.0
- Pricing pages (read 2026-10-25): Clerk, Supabase, Auth0, Firebase
- Code on GitHub: huseyinbabal/keycloak-react-go