Skip to content
Huseyin Babal
Go back

Keycloak with React and Go: login with PKCE, JWT checks and roles, from one compose file

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.

ServiceFree up toFirst paid tierEnterprise SSO
Clerk50,000 monthly retained users per appPro $25/mo, then $0.02 per user from 50,001 to 100k, $0.018 up to 1M1 connection included, each extra $75/mo
Supabase Auth50,000 MAUPro from $25/mo with 100,000 MAU, then $0.00325 per MAUSAML: 50 MAU included, then $0.015 per MAU, not on Free
Auth025,000 MAUB2C Essentials $700/mo at 10k MAU, Professional $1,600/mo at 10k+$100/mo per connection
Firebase Auth50,000 MAUIdentity Platform pricing above thatSAML 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):

StepWhat the React app sends
before redirecting to logincode_challenge = base64url(SHA-256(random code_verifier)), code_challenge_method=S256
exchanging the codethe original code_verifier
Keycloak checksSHA-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:

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.

RequestStatusBody
no Authorization header401missing token
Bearer made.up.token401invalid token: oidc: malformed jwt: illegal base64 data at input byte 4
bob’s valid token403bob does not have the reader role
alice’s valid token200the 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:

LoadPlan for
password logins1 vCPU per 15 logins/s
refresh token or client credentials grants1 vCPU per 120 requests/s
memoryabout 1250 MB per pod with 10,000 cached sessions
headroom150% 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

FeatureWhere in the admin console
Users from Active Directory or LDAPUser federation
Sign in with Google, GitHub, Microsoft, or any OIDC/SAML providerIdentity providers
One-time codes or security keys as a second stepAuthentication flows
Self-registration, forgot password, passkeysRealm settings → Login
Users grouped by customer companyOrganizations
Your own login page lookThemes

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


Share this post:

Previous Post
Golang profiling with pprof: a slow regex and a leaking slice, found and measured