Skip to content
Back to skills

SecuringAppsReactRest

ASecurity

Wire Keycloak login into a React SPA with oidc-spa, and register or repair the OIDC client the app needs, driven through the Keycloak Admin REST API. Use this whenever someone wants login added to a browser app - login/logout controls, route protection, reading the signed-in user - against a self-managed Keycloak realm. Covers the oidc-spa v10 builder API (which replaced the removed createReactOidc), why the registered redirect URI needs a trailing slash, why updating an existing client must ...

  • 4 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 15, 2026
ai-agentsgobashreactapi

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 15, 2026

npx -y skills add p2-inc/keycloak-skills --skill securingAppsReactRest --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of SecuringAppsReactRest?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for SecuringAppsReactRest
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/p2-inc-securingappsreactrest/badge)](https://www.skillsdirectory.com/skills/p2-inc-securingappsreactrest)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: securingAppsReactRest
description: >-
  Wire Keycloak login into a React SPA with oidc-spa, and register or repair the OIDC client the
  app needs, driven through the Keycloak Admin REST API. Use this whenever someone wants login
  added to a browser app - login/logout controls, route protection, reading the signed-in user -
  against a self-managed Keycloak realm. Covers the oidc-spa v10 builder API (which replaced the
  removed createReactOidc), why the registered redirect URI needs a trailing slash, why updating an
  existing client must be a read-merge-PUT rather than a hand-built body, and why web origins are a
  separate setting from redirect URIs that fails only in a real browser.
---

<!-- Copyright 2026 Phase Two, Inc. -->
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->

# React + Keycloak login — via the Admin REST API

## What this is

A browser-only React app logging users into Keycloak with authorization code + PKCE, using
[`oidc-spa`](https://docs.oidc-spa.dev/). The client is **public** — a browser bundle cannot hold a
secret, so there is none, and PKCE replaces it.

Two halves, both required: **the app's source**, and **the client registration in Keycloak**. A
correct app against a stale client does not log anyone in.

## Half 1: the app

**Verified against `oidc-spa` 10.2.11.** `oidcSpa` is a **builder**, not a config function.

`src/oidc.ts`:

```ts
import { oidcSpa } from "oidc-spa/react-spa";
import { z } from "zod";

export const {
    bootstrapOidc, useOidc, getOidc, withLoginEnforced, OidcInitializationGate
} = oidcSpa
    .withExpectedDecodedIdTokenShape({
        decodedIdTokenSchema: z.object({
            sub: z.string(),
            name: z.string().optional(),
            preferred_username: z.string().optional()
        })
    })
    .createUtils();

bootstrapOidc({
    implementation: "real",
    issuerUri: "http://localhost:8080/auth/realms/<realm>",   // INCLUDES /realms/<realm>
    clientId: "<client-id>"
});
```

`src/main.tsx` — the wrapper is `OidcInitializationGate`. **There is no `OidcProvider` in v10.**

```tsx
<OidcInitializationGate>
    <App />
</OidcInitializationGate>
```

Reading state, login, logout:

```tsx
const { isUserLoggedIn } = useOidc();
const { login } = useOidc({ assert: "user not logged in" });
const { decodedIdToken, logout } = useOidc({ assert: "user logged in" });
// <button onClick={() => login()}>   /   <button onClick={() => logout({ redirectTo: "home" })}>
```

Claims live on `decodedIdToken`. Access token for API calls:
`await (await getOidc()).getAccessToken()`.

> **If you remember a different API, you are remembering v8.** `createReactOidc` and the whole
> `oidc-spa/react` entry point were **removed in v9**. `createOidcProvider` / `createUseOidc` are
> v3-era. Importing any of them fails to resolve.

PKCE is automatic and cannot be disabled — the library enforces `S256`.

## Half 2: the client

```bash
BASE=http://localhost:8080/auth      # include the relative path if one is configured
REALM=<realm>                        # the app's realm — NOT master

ADMIN_TOKEN=$(curl -s -X POST "$BASE/realms/master/protocol/openid-connect/token" \
  -d client_id=admin-cli -d grant_type=password \
  -d username=<admin-user> -d password=<admin-password> \
  | jq -r .access_token)
H="Authorization: Bearer $ADMIN_TOKEN"
```

`$REALM` is the app's realm, not `master` — you authenticate *as* a master admin and act *on* the
app's realm. The token is short-lived; a run that starts working then 401s has simply expired.

**Check what exists first.** `clientId` is not the UUID: `clientId` is the string in the app's
config; `id` is the server-generated UUID every `/clients/{uuid}` path needs.

```bash
ID=$(curl -s "$BASE/admin/realms/$REALM/clients?clientId=<client-id>" -H "$H" | jq -r '.[0].id')
```

Empty array → the client does not exist, create it with `POST /admin/realms/$REALM/clients`.
Otherwise you are updating one that does.

### Updating an existing client: read, merge, PUT

> ⚠️ **`PUT /admin/realms/{realm}/clients/{uuid}` takes the WHOLE client representation.** Top-level
> fields you omit are **blanked**, silently, with a `204 No Content` that looks like success. Send
> only `{"redirectUris": [...]}` and you have just wiped the client's name, its protocol mappers, its
> flow bindings, its scopes and its production web origins. There is no PATCH and no partial-update
> endpoint. Nothing merges this for you — that is your job here.

```bash
# 1. READ the current representation whole
curl -s "$BASE/admin/realms/$REALM/clients/$ID" -H "$H" > /tmp/client.json

# 2. MERGE — append to the existing lists, don't replace them
jq '.redirectUris = (.redirectUris + ["http://localhost:5173/"] | unique)
  | .webOrigins   = (.webOrigins   + ["http://localhost:5173"]  | unique)
  | .publicClient = true
  | .standardFlowEnabled = true
  | .attributes["pkce.code.challenge.method"] = "S256"' \
  /tmp/client.json > /tmp/client-new.json

# 3. LOOK at what you are about to send. Anything you did not intend is a bug.
diff <(jq -S . /tmp/client.json) <(jq -S . /tmp/client-new.json)

# 4. PUT the whole thing back — expect 204
curl -s -o /dev/null -w '%{http_code}\n' -X PUT "$BASE/admin/realms/$REALM/clients/$ID" \
  -H "$H" -H 'Content-Type: application/json' --data-binary @/tmp/client-new.json
```

`/tmp/client.json` can contain the client secret for a confidential client — treat these temp files
as credential material and delete them afterwards.

**Never `DELETE` a live client to change an attribute.** It destroys its protocol mappers, role
mappings, secret and sessions. A rebuilt client can log a user in while having silently lost
configuration other systems depend on.

### Three things that decide whether this works

1. **The redirect URI ends with a trailing slash.** oidc-spa returns to the app's *base URL*, so a
   Vite app on port 5173 needs `http://localhost:5173/` registered — with the slash. Keycloak
   matches redirect URIs exactly and rejects the callback outright without it
   (`Invalid parameter: redirect_uri`, before the login form).

2. **Web origins are a SEPARATE setting from redirect URIs, and they fail differently.** The
   redirect URI authorizes where Keycloak sends the user *back*; the web origin authorizes the
   **CORS** origin of the browser's *token* call. Register only the redirect URI and login appears
   to succeed, then the token request is blocked in the browser — while every scripted, server-side
   test passes, because nothing in a script triggers a CORS preflight. Set `webOrigins` to the app's
   origin (`http://localhost:5173`, no trailing slash) or `"+"`.

3. **`attributes` merge; top-level fields replace.** Keycloak iterates only the attribute keys
   present in the payload, so you cannot delete an attribute by omitting it — send it explicitly as
   JSON `null`. Top-level fields behave the opposite way, which is what makes step 1 above
   non-negotiable.

## Verify

```bash
curl -s "$BASE/admin/realms/$REALM/clients/$ID" -H "$H" \
  | jq '{clientId, enabled, publicClient, standardFlowEnabled, redirectUris, webOrigins,
         pkce: .attributes["pkce.code.challenge.method"],
         mappers: [.protocolMappers[]?.name]}'
```

Confirm `mappers` still lists everything it listed before you started — that is the check that
separates an update from an accidental rebuild. Then run `npm run build`, and do a real browser
login: the token call succeeding is the half a scripted test cannot see.

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| `Invalid parameter: redirect_uri` before the login form | trailing slash missing | register `http://host:port/` |
| Login works, then a CORS error on the token call | `webOrigins` not set | add the origin, or `"+"` |
| `Cannot find module 'oidc-spa/react'` | v8 import on a v9/v10 install | use `oidc-spa/react-spa` |
| `decodedIdToken` undefined | read without `assert` | narrow with `assert: "user logged in"` |
| Config that existed before the change is gone | a partial `PUT` blanked it | always read-merge-`PUT`; restore from a pre-change `GET` if you have one |
| `401` on every call partway through | the admin token expired | re-mint it; it lasts minutes |
| `409 Conflict` on `POST /clients` | the `clientId` is taken | you are on the update path, not create |

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.