Tanmay's Blog

Setting up AuthN/AuthZ with Keycloak (OpenID Connect) for a REST Go API

Most Go services eventually need to answer two questions about every request: who is calling? and are they allowed to do this? The first is authentication, the second is authorization, and it's tempting to hand-roll both with a users table and a middleware that checks a session cookie.

That works until you need password resets, multi-factor auth, single sign-on, social login, an admin UI to manage it all, and a second service that has to trust the same identities. At that point you're maintaining an identity provider as a side project.

Keycloak is an open-source identity and access management server that does all of that already. In this tutorial we'll wire it up to a small Go API and end with something concrete: two endpoints, two users, and a role that lets one user through and stops the other.

Here's what we're building.

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   1. sign in       โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚        Client        โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚        Keycloak 26         โ”‚
โ”‚   curl ยท SPA ยท app   โ”‚  โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚   realm  DEMOREALM         โ”‚
โ”‚                      โ”‚     access_token   โ”‚   client rag-api           โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
           โ”‚                                       โ”‚
           โ”‚ 2. GET /v1/documents                  โ”‚ 3. JWKS public keys
           โ”‚    Authorization: Bearer <token>      โ”‚    fetched once at
           โ”‚                                       โ”‚    startup, cached
           โ–ผ                                       โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                             Go API   :3333                             โ”‚
โ”‚                                                                        โ”‚
โ”‚   4a. verify signature + aud  โ€”  locally, no network call              โ”‚
โ”‚   4b. check resource_access["rag-api"].roles for the required role     โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                 โ”‚                                  โ”‚
  5a. valid +    โ”‚                                  โ”‚ 5b. otherwise
  role present   โ–ผ                                  โ–ผ
     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”        โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
     โ”‚        200 OK        โ”‚        โ”‚  401  cannot tell who      โ”‚
     โ”‚     handler runs     โ”‚        โ”‚  403  known, not allowed   โ”‚
     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜        โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Steps 2-5 make no network call to Keycloak. Verification is local
cryptography against public keys cached at startup.

The important detail is step 3. Our Go service downloads Keycloak's public signing keys once, at startup, and then verifies every token locally using cryptography. Keycloak is not on the request path. If Keycloak goes down for maintenance, already-issued tokens keep working until they expire, and your API keeps serving traffic.

Everything below was run against these versions:

Keycloak 26.7.2 (quay.io/keycloak/keycloak)
Go 1.25.6
github.com/coreos/go-oidc/v3 v3.20.0

Our example service is a small document catalog โ€” it lists documents in a collection, and reports statistics about the search index. Two endpoints, deliberately at different privilege levels:

Endpoint What it does Required role
GET /v1/documents?collection=handbook List documents in a collection docs-read
GET /v1/index/stats?collection=handbook Vector count, dimensions, last index time index-admin

Everyone who can use the product should be able to search. Only operators should see index internals. That split is the whole point of role-based authorization, and it gives us a real failure case to test rather than a theoretical one.


Step 1: Run Keycloak

docker run -d --name keycloak -p 127.0.0.1:8080:8080 \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:26.7.2 start-dev

A few notes on that command, because each flag is doing something deliberate.

start-dev runs Keycloak in development mode: an in-memory database, no TLS, and relaxed hostname checking. Every one of those is wrong for production and right for the next twenty minutes. Be aware that the in-memory database means your configuration disappears when the container is removed โ€” we'll cover exporting it at the end.

KC_BOOTSTRAP_ADMIN_USERNAME creates the initial administrator. If you've read older tutorials you may have seen KEYCLOAK_USER or KEYCLOAK_ADMIN; those names are no longer current. The frustrating part is that using an outdated name isn't an error โ€” the container starts happily with no admin account at all, and you discover it at the login screen.

Binding to 127.0.0.1:8080 rather than 0.0.0.0:8080 keeps this development identity server off your local network. There's no reason for the cafรฉ wifi to reach it.

Give it a few seconds, then open the admin console:

http://localhost:8080/admin/master/console/

Log in with admin / admin.


Step 2: Create a realm

A realm is an isolated universe of users, credentials, roles, and clients. Users in one realm cannot log into another; they don't even know it exists. Keycloak ships with a realm called master, which exists to administer Keycloak itself โ€” think of it as the root account, not as the place your application's users live.

So we'll make our own. Click the realm dropdown in the top-left sidebar, choose Create realm, and name it DEMOREALM.

That dropdown is also how you move between realms later, and it's worth remembering โ€” a surprising number of "the setting I just changed disappeared" moments turn out to be someone configuring the wrong realm.


Step 3: Give the realm its own administrator

Right now, the only way to administer DEMOREALM is with the master admin account. But that account can administer every realm on the server. If a teammate needs to manage this one realm, handing them master credentials gives them all of them.

Keycloak's answer is a user who lives inside DEMOREALM and holds administrative rights scoped to it. Let's create one.

Go to Users โ†’ Add user. Set the username to demo-admin. Keycloak 26 has a feature called declarative user profile switched on by default, which requires an email, first name, and last name for every user โ€” so fill those in too. (Skip them and you'll meet a confusing error shortly; we'll come back to it.)

Save, then open the Credentials tab and set a password. Turn Temporary off. A temporary password forces a password reset at first login, which is sensible for real users and a nuisance here โ€” it also makes the token request we do later fail with a message that doesn't mention passwords at all.

Now the interesting part: granting administrative rights. Open the Role mapping tab and click Assign role.

You'll see a list of realm roles, and nothing that looks like what you want. Change the dropdown from Filter by realm roles to Filter by clients. Client roles are hidden until you do this, and this single dropdown is where most people get stuck.

Search for realm-admin and assign the one belonging to realm-management.

A trap worth naming. You'll see a role called manage-realm in that same list, and it sounds like exactly what you want. It isn't โ€” it's one narrow permission. realm-admin is a composite role: a role that contains other roles. Assigning it grants manage-realm, manage-users, manage-clients, view-events and the rest in one go. Because the list is alphabetical and paginated, realm-admin usually sits on a later page than the manage-* and query-* entries โ€” search for it rather than scrolling.

Composite roles are generally useful: define editor as docs-read + docs-write once, assign it everywhere, and change what "editor" means in a single place later.

Your new admin can now sign in at their own console, which shows only DEMOREALM:

http://localhost:8080/admin/DEMOREALM/console/

Step 4: Create a client

A client in Keycloak is any application that participates in authentication โ€” a frontend, a mobile app, a backend service, or Keycloak's own admin console (which is itself a client called security-admin-console).

Before clicking anything, it's worth being precise about what our Go service actually needs from a client, because the answer is smaller than most tutorials imply.

Our service validates tokens. It downloads Keycloak's public keys and checks signatures locally. It never authenticates itself to Keycloak, never holds a secret, and never makes a network call while handling a request. So the client exists for exactly two reasons:

  1. It's the audience โ€” the identifier our verifier checks the token was issued for.
  2. It's the namespace our roles live under, at resource_access.rag-api.roles inside the token.

That's it. Let's create it. Clients โ†’ Create client:

General settings

Whatever you type as the Client ID appears in your Go code and in every token, so pick it deliberately.

Capability config

Login settings โ€” leave everything blank and save.

Those toggles deserve an explanation, because they're the part of Keycloak people most often set by guesswork.

What the capability toggles actually mean

Each toggle enables one grant type โ€” one way of obtaining a token.

Standard flow is the Authorization Code flow, and it's the right answer for anything with a browser. The user is redirected to Keycloak's login page, logs in there, and comes back to your app with a short-lived code, which your app exchanges for tokens over a back channel. The token never appears in the URL bar. Combined with PKCE, a stolen code is useless to anyone else. This belongs on your frontend client, not on the API.

Direct access grants is the Resource Owner Password flow: you send a username and password straight to Keycloak's token endpoint and get tokens back. We enable it because it lets us fetch a token from a terminal with curl, which makes testing tractable. It is not how real users should log in โ€” your application sees the raw password, which means no MFA, no social login, no password recovery. You've bypassed Keycloak's login page and most of the reason you're using Keycloak.

Service accounts roles is the Client Credentials flow: the client authenticates as itself, with no user involved. The token's subject is the service, not a person. This is what you'd enable if rag-api needed to call other protected services โ€” a nightly reindex job, say, running at 3am with nobody logged in. We don't need it here.

Implicit flow returns tokens directly in the URL fragment. It's deprecated. Leave it off.

Why client authentication is off

Client authentication means the client proves its own identity with a secret. That only matters for software that requests tokens. Our Go service doesn't request anything โ€” it validates. Right now the only thing requesting a token is our terminal, so switching this on would buy us one extra -d client_secret=โ€ฆ on a curl command, plus a secret to manage.

The honest tradeoff: a public client with direct access grants enabled means anyone who can reach Keycloak can attempt password guesses against that client ID with no secret gate. On localhost that's irrelevant. Exposed to a network it's a brute-force surface โ€” and Keycloak's brute-force detection, under Realm settings โ†’ Security defenses, is off by default.

The rule of thumb: can the thing holding the secret actually keep it? A backend server can, so it should be confidential. A browser SPA or a mobile app can't โ€” anyone can read the bundle โ€” so those stay public and use PKCE instead. Turn client authentication on when a real backend starts requesting tokens, or when you enable service accounts, which require it.


Step 5: Add an audience mapper

This step doesn't appear in most tutorials, and skipping it produces a failure that's genuinely hard to diagnose โ€” so it's worth understanding rather than just copying.

When our Go code creates its verifier, it will pass the client ID:

verifier := provider.Verifier(&oidc.Config{ClientID: "rag-api"})

That tells the library: only accept tokens whose aud (audience) claim contains rag-api. The audience claim answers "who was this token minted for", and checking it is what stops a token issued for some other service from being replayed against yours.

Here's the subtlety. Keycloak does not put your client in aud simply because you asked it for a token. The client that requested the token goes into a different claim, azp (authorized party). What populates aud is a built-in mapper called audience resolve, which ships in every realm's roles client scope, and its rule is:

add a client to aud if the token already carries roles for that client.

Follow that through and the trap appears:

So the check works by coincidence, and breaks for the first user you onboard without a role. Let's make it explicit instead.

Go to Clients โ†’ rag-api โ†’ Client scopes โ†’ rag-api-dedicated โ†’ Add mapper โ†’ By configuration โ†’ Audience. Name it rag-api-audience, set Included Client Audience to rag-api, and save.

Now every token issued for this client names it as the audience, no matter which roles the user holds.

(The alternative is SkipClientIDCheck: true in the Go config, validating azp yourself instead. That's defensible while you're wiring things up, but the mapper is the correct fix and takes about thirty seconds.)


Step 6: Create roles

A role is a named permission. Keycloak has two kinds: realm roles, which are global to the realm, and client roles, which are scoped to one client. Ours are client roles, because docs-read only means something in the context of this API.

Go to Clients โ†’ rag-api โ†’ Roles โ†’ Create role and add two:


Step 7: Create two users

We need two users to see authorization actually working. One with limited access, one with full access.

Under Users โ†’ Add user, create:

Username Password Roles
analyst analyst123 docs-read
librarian librarian123 docs-read, index-admin

For each one: fill in email, first name, and last name; set the password with Temporary off; then Role mapping โ†’ Assign role โ†’ Filter by clients and assign the roles above.

If you skipped the name and email fields, your token request will fail with invalid_grant: Account is not fully set up. That message sounds like a credentials problem but isn't โ€” Keycloak 26's declarative user profile has flagged the account with a VERIFY_PROFILE required action, which blocks login until the profile is complete. Fill in the missing fields on the user's Details tab and the required action clears.

Creating a user who lacks a permission feels like busywork, but it's the only way to test that your authorization code actually refuses anyone. A role check that has never returned false is a role check you haven't tested.


Step 8: Get a token and look inside it

Everything is configured. Let's get a token:

curl -s -X POST \
  http://localhost:8080/realms/DEMOREALM/protocol/openid-connect/token \
  -d client_id=rag-api \
  -d grant_type=password \
  -d username=analyst \
  -d password=analyst123 | jq

You'll get back several things:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICIyb2kx...",
  "expires_in": 300,
  "refresh_expires_in": 1800,
  "refresh_token": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICIwNTkz...",
  "token_type": "Bearer",
  "scope": "email profile"
}

Three token types show up in OIDC and they're easy to confuse:

If you got an error instead, the code maps cleanly to a cause:

Error What it means
unauthorized_client Direct access grants is off on the client
invalid_client Client authentication is on โ€” send a secret, or turn it off
invalid_grant: Account is not fully set up Temporary password, or an incomplete user profile
invalid_grant: Invalid user credentials Genuinely the wrong password

Reading the token

An access token is a JWT โ€” three base64url segments separated by dots: a header naming the signing algorithm and key, a payload of claims, and a signature over the first two. Anyone can read the payload; only Keycloak can produce a valid signature.

Rather than pasting tokens into a website, decode locally:

curl -s -X POST \
  http://localhost:8080/realms/DEMOREALM/protocol/openid-connect/token \
  -d client_id=rag-api -d grant_type=password \
  -d username=analyst -d password=analyst123 \
| jq -r '.access_token | split(".")[1]
         | gsub("-";"+") | gsub("_";"/")
         | . + ("=" * ((4 - (length % 4)) % 4) // "")
         | @base64d | fromjson
         | {aud, azp, resource_access}'

The gsub and padding calls are doing real work: JWTs use base64url (with - and _ instead of + and /) and omit trailing padding. The obvious cut -d. -f2 | base64 -d pipeline fails on macOS for exactly this reason, usually with a jq parse error that makes it look like the token is malformed when it's fine.

For analyst, that prints:

{
  "aud": ["rag-api", "account"],
  "azp": "rag-api",
  "resource_access": {
    "rag-api": { "roles": ["docs-read"] },
    "account": { "roles": ["manage-account", "view-profile"] }
  }
}

And for librarian:

{
  "aud": ["rag-api", "account"],
  "azp": "rag-api",
  "resource_access": {
    "rag-api": { "roles": ["docs-read", "index-admin"] }
  }
}

This is the shape our Go code reads: resource_access โ†’ client ID โ†’ roles. Note that aud contains rag-api โ€” that's our audience mapper from step 5 doing its job.


Step 9: Write the Go service

Now the code. We need one dependency:

go get github.com/coreos/go-oidc/v3/oidc

go-oidc handles OIDC discovery, fetches and caches the realm's public keys, validates the signature, and checks the standard claims โ€” issuer, audience, expiry. What it doesn't do is roles, because roles aren't part of the OIDC spec; that part is ours.

Reading the claims we care about

// Keycloak nests client roles under the client ID, so this stays a map rather
// than a struct with the client name in a tag โ€” renaming the client in
// Keycloak then costs one environment variable instead of a rebuild.
type claims struct {
	ResourceAccess map[string]struct {
		Roles []string `json:"roles"`
	} `json:"resource_access"`
	PreferredUsername string `json:"preferred_username"`
}

func hasRole(c claims, clientID, role string) bool {
	for _, got := range c.ResourceAccess[clientID].Roles {
		if got == role {
			return true
		}
	}
	return false
}

A JWT payload has a lot of claims and we only need two. Keying ResourceAccess by client ID matters more than it looks: it means hasRole can't accidentally match a role of the same name granted by a different client, which is a real concern once a realm has several.

The middleware

type authorizer struct {
	verifier *oidc.IDTokenVerifier
	clientID string
}

// require wraps a handler so it runs only for callers holding role on this
// client. 401 means we could not establish who the caller is; 403 means we
// know exactly who they are and they may not do this.
func (a *authorizer) require(role string, next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
		if raw == "" {
			fail(w, http.StatusUnauthorized, "missing bearer token")
			return
		}
		token, err := a.verifier.Verify(r.Context(), raw)
		if err != nil {
			fail(w, http.StatusUnauthorized, "token verification failed: "+err.Error())
			return
		}
		var c claims
		if err := token.Claims(&c); err != nil {
			fail(w, http.StatusUnauthorized, "could not read claims: "+err.Error())
			return
		}
		if !hasRole(c, a.clientID, role) {
			fail(w, http.StatusForbidden, c.PreferredUsername+" is missing the "+role+" role")
			return
		}
		next(w, r)
	}
}

Three details worth calling out.

Strip the Bearer prefix. Real clients send Authorization: Bearer eyJhbGciโ€ฆ. Pass that string to the verifier unmodified and every request fails with a base64 error, because Bearer isn't part of the token.

401 and 403 are different answers. 401 Unauthorized means we could not establish who you are โ€” no token, expired token, bad signature. 403 Forbidden means we know exactly who you are, and you may not do this. Returning 401 for a missing role, which older examples do, tells the client to go get a new token โ€” which will fail identically, forever.

Verification is local. a.verifier.Verify performs no network call in the normal case. It checks the signature against cached public keys, then validates issuer, audience, and expiry.

Wiring it up

func main() {
	var (
		issuer   = env("KEYCLOAK_ISSUER", "http://localhost:8080/realms/DEMOREALM")
		clientID = env("KEYCLOAK_CLIENT_ID", "rag-api")
		addr     = env("ADDR", ":3333")
	)

	// Discovery runs once, at startup. If Keycloak is unreachable we fail
	// loudly here instead of returning 500s on the first request.
	provider, err := oidc.NewProvider(context.Background(), issuer)
	if err != nil {
		log.Fatalf("oidc discovery against %s: %v", issuer, err)
	}
	auth := &authorizer{
		verifier: provider.Verifier(&oidc.Config{ClientID: clientID}),
		clientID: clientID,
	}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /v1/documents", auth.require("docs-read", listDocuments))
	mux.HandleFunc("GET /v1/index/stats", auth.require("index-admin", indexStats))

	log.Printf("listening on %s (issuer %s, audience %s)", addr, issuer, clientID)
	log.Fatal(http.ListenAndServe(addr, mux))
}

oidc.NewProvider hits Keycloak's discovery document at /.well-known/openid-configuration, which tells it where the keys live. Do this once, at startup, not inside your handler. Constructing the provider per request โ€” which a lot of copied code does โ€” means two network round trips to Keycloak on every single API call, and it turns Keycloak into a hard dependency of your hot path.

Note the issuer URL has no /auth segment. Keycloak dropped that path prefix when it moved to Quarkus, and it's the single most common reason a copied config produces a 404 from a URL that looks correct.

The routing is plain net/http. Since Go 1.22, ServeMux matches on method and path pattern, so "GET /v1/documents" works without a third-party router.

Two more things go-oidc gives you for free. It re-fetches the key set when it sees a token signed by an unknown key ID, so signing key rotation needs no work from you. And because verification is local, Keycloak being down doesn't take your API down โ€” already-issued tokens validate until they expire. That's the argument for JWT validation over token introspection, and it's also why that five-minute lifetime matters: it bounds how long a user you just disabled keeps working.


Step 10: Test every path

Start the service:

go run ./cmd/api
# listening on :3333 (issuer http://localhost:8080/realms/DEMOREALM, audience rag-api)

Grab tokens for both users:

tok() {
  curl -s -X POST \
    http://localhost:8080/realms/DEMOREALM/protocol/openid-connect/token \
    -d client_id=rag-api -d grant_type=password \
    -d username=$1 -d password=$2 | jq -r .access_token
}

ANALYST=$(tok analyst analyst123)
LIBRARIAN=$(tok librarian librarian123)

No token at all:

curl -s "localhost:3333/v1/documents?collection=handbook"
# {"status":"FAILED","code":401,"message":"missing bearer token"}

A malformed token:

curl -s -H "Authorization: Bearer abc.def.ghi" \
  "localhost:3333/v1/documents?collection=handbook"
# {"status":"FAILED","code":401,
#  "message":"token verification failed: oidc: malformed jwt: ..."}

analyst listing documents โ€” has docs-read, so this works:

curl -s -H "Authorization: Bearer $ANALYST" \
  "localhost:3333/v1/documents?collection=handbook"
# [{"title":"Onboarding Checklist","pages":12,"updated":"2026-07-02"},
#  {"title":"Expense Policy","pages":8,"updated":"2026-05-19"},
#  {"title":"Security Baseline","pages":24,"updated":"2026-08-11"}]

analyst reading index stats โ€” a perfectly valid token, refused:

curl -s -H "Authorization: Bearer $ANALYST" \
  "localhost:3333/v1/index/stats?collection=handbook"
# {"status":"FAILED","code":403,
#  "message":"analyst is missing the index-admin role"}

librarian reading the same endpoint โ€” same request, different user, allowed:

curl -s -H "Authorization: Bearer $LIBRARIAN" \
  "localhost:3333/v1/index/stats?collection=handbook"
# {"collection":"handbook","vectors":4482,"dimensions":1536,
#  "lastIndexed":"2026-08-11T09:14:00Z"}

Those last two are the ones that matter. Same endpoint, same code path, two users, two different answers โ€” decided entirely by a role assigned through a web console, with no deploy and no database migration. That's what you bought.

A test that doesn't need a server

The role check itself is worth a unit test. It needs no network and catches the case that's easy to get wrong:

func TestHasRole(t *testing.T) {
	var c claims
	c.ResourceAccess = map[string]struct {
		Roles []string `json:"roles"`
	}{
		"rag-api":          {Roles: []string{"docs-read"}},
		"realm-management": {Roles: []string{"realm-admin"}},
	}

	cases := []struct {
		name         string
		client, role string
		want         bool
	}{
		{"role held", "rag-api", "docs-read", true},
		{"role not held", "rag-api", "index-admin", false},
		{"role belongs to another client", "rag-api", "realm-admin", false},
		{"unknown client", "other-api", "docs-read", false},
	}
	for _, tc := range cases {
		if got := hasRole(c, tc.client, tc.role); got != tc.want {
			t.Errorf("%s: hasRole(%q, %q) = %v, want %v",
				tc.name, tc.client, tc.role, got, tc.want)
		}
	}
}

The third case is the interesting one: a user who is a realm administrator holds realm-admin โ€” but on the realm-management client, not ours. Our check must not confuse the two.


Before this goes to production

Everything above is a development setup. The gap to a real deployment is mostly on the Keycloak side.

Turn off direct access grants. We enabled the password grant so we could get a token from a terminal. In production it means your application handles raw passwords, which rules out MFA, social login, and password recovery. Real logins should use Standard flow with PKCE from a frontend client โ€” a separate, public client with redirect URIs configured. Your API client keeps validating and nothing about the Go code changes.

Replace start-dev. Production mode wants an external database (Postgres), TLS, and an explicit KC_HOSTNAME. The bootstrap admin is meant to be replaced with a real account and then removed.

Export your realm before you lose it. start-dev keeps everything in memory. Run kc.sh export --dir /opt/keycloak/data/export --realm DEMOREALM inside the container and commit the result, or rebuild this configuration by hand every time the container is recreated.

Enable brute-force detection under Realm settings โ†’ Security defenses. It's off by default.

Decide on token lifetime deliberately. Five minutes is the default access token lifetime. It's the window during which a user you just disabled still gets through. Shorter is safer and costs more refreshes.

Give the service its own client if it needs to call other services. That's a separate, confidential client with service accounts enabled โ€” distinct from the client it validates tokens against.

The shape to aim for: a public frontend client using Standard flow with PKCE, a Go service that only ever validates tokens, and direct access grants switched off everywhere.


Appendix: scripting the setup

Clicking through the console is the right way to learn what the pieces are. Once you know, it's tedious. Keycloak's admin REST API can do the whole thing, which is also how you'd provision a realm in CI:

#!/usr/bin/env bash
set -euo pipefail
KC=http://localhost:8080
REALM=DEMOREALM

T=$(curl -s -X POST "$KC/realms/$REALM/protocol/openid-connect/token" \
  -d client_id=admin-cli -d grant_type=password \
  -d username=demo-admin -d password=YOUR_PASSWORD | jq -r .access_token)
A=(-H "Authorization: Bearer $T" -H "Content-Type: application/json")

# the client
curl -s "${A[@]}" -X POST "$KC/admin/realms/$REALM/clients" -d '{
  "clientId": "rag-api",
  "protocol": "openid-connect",
  "publicClient": true,
  "standardFlowEnabled": false,
  "directAccessGrantsEnabled": true,
  "serviceAccountsEnabled": false
}'

CID=$(curl -s "${A[@]}" "$KC/admin/realms/$REALM/clients?clientId=rag-api" \
      | jq -r '.[0].id')

# the audience mapper from step 5
curl -s "${A[@]}" -X POST \
  "$KC/admin/realms/$REALM/clients/$CID/protocol-mappers/models" -d '{
  "name": "rag-api-audience",
  "protocol": "openid-connect",
  "protocolMapper": "oidc-audience-mapper",
  "config": {
    "included.client.audience": "rag-api",
    "access.token.claim": "true",
    "id.token.claim": "false"
  }
}'

# the roles
for r in docs-read index-admin; do
  curl -s "${A[@]}" -X POST \
    "$KC/admin/realms/$REALM/clients/$CID/roles" -d "{\"name\":\"$r\"}"
done

Users follow the same pattern: POST /users with a credentials array and temporary: false, then POST /users/{id}/role-mappings/clients/{clientUuid} with the role objects fetched from /clients/{clientUuid}/roles/{roleName}. Remember to include firstName, lastName and email, or you'll hit the "Account is not fully set up" error from step 7.


Wrapping up

The whole thing comes down to a small division of labour. Keycloak owns identity: who exists, what they can prove, and which roles they hold. Your Go service owns nothing about identity at all โ€” it verifies a signature against a cached public key, reads a list of role strings out of the payload, and decides. One dependency, no session store, no user table, and no network call on the request path.

Adding a third endpoint is one line and one role in a web console. Adding a second service means pointing it at the same issuer. That's the payoff for the twenty minutes of clicking.