Build Your Own Keycloak Sign-In App
Build a Vite app that signs users in with Keycloak using OIDC and PKCE.
Introduction
30 Second Summary
Typing a password directly into every app spreads sensitive information across more places. A shared sign-in service lets each app learn who returned without ever seeing the password.
In this project, you will build a local Vite app that sends users to Keycloak for sign-in before welcoming them back with safe identity details. You will use OpenID Connect to test sign-in, reload behavior, quick SSO return, and sign-out without exposing raw tokens.
What You'll Build
Open your finished app in Firefox to watch a signed-out landing page become a welcome screen with four identity cards after Keycloak returns you.
By the end of this project, you'll have:
- A real sign-in journey that sends Firefox to Keycloak before returning myuser to your app.
- A signed-in view with four readable cards for identity details, client roles, and token expiry.
- A repeatable browser demo covering memory-only reload behavior, quick SSO return, explicit sign-out, and a production build.
- Secret Mission: Add an app-admin client role. Show an Admin badge only when a fresh token includes that role.
Are there any prerequisites?
This project continues from Build an OIDC Identity Lab with Keycloak, with its existing Keycloak container kept or paused. Your Linux device needs Docker, Firefox, and nano.
Step 1 sets up Node.js, npm, Vite, and keycloak-js.
Before We Start
A useful sign-in journey starts with a clear boundary between visitors and signed-in users. This checkpoint locks in your fictional app's purpose before the hands-on work begins.
Prepare the Local App Workshop
Your sign-in app now has a purpose. It still needs a local workshop where you can build the page without disturbing the identity data you saved in Project 1.
A local browser app needs a JavaScript runtime. Node.js supplies that runtime. Vite turns your files into a page you can test locally.
Your identity data already lives in the saved Keycloak container. Reusing it with Docker preserves myrealm, myuser, and myclient. Firefox gives you a visible check that both local services are ready.
In this step, get ready to:
- Install nvm, Node.js, and npm for your Linux account.
- Resume the saved Keycloak container with its Project 1 data.
- Create a vanilla Vite project with pinned packages.
Install Node.js and npm
The nvm version manager lets your Linux account install a specific Node.js release without changing the system runtime. The matching npm release arrives with Node.js.
- Check which downloader is available by running these commands:
command -v curl
command -v wget
What do these checks tell you?
Each command prints a path when that downloader is available. A blank result means that particular downloader is unavailable.
Use the tab that matches the downloader path shown in your terminal.
curl is available
- Install nvm 0.40.8 with the available downloader by running:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash
Installer did not finish?
This command downloads the official nvm installer before running it for your Linux account. Check your network connection if the download stops early.
Still stuck? Help me troubleshoot the nvm curl installer on Linux.
Only wget is available
- Install nvm 0.40.8 with the available downloader by running:
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash
Installer did not finish?
This command downloads the official nvm installer before running it for your Linux account. Check your network connection if the download stops early.
Still stuck? Help me troubleshoot the nvm wget installer on Linux.
Neither is available
The nvm installer needs one of these downloaders. Your Linux software manager can install either curl or wget.
- Install curl or wget with your Linux software manager.
- Repeat the downloader checks after the installation finishes.
- Return to the matching tab when a path appears.
Need help choosing a downloader?
Package-manager steps vary between Linux distributions. Help me install curl or wget on my Linux distribution.
- Load nvm into the current terminal by running the official shell lines:
export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
What do these shell lines do?
The first line locates your nvm installation directory. The second line loads nvm into this terminal session so its commands are available immediately.
- Confirm nvm is loaded at version 0.40.8 by running:
command -v nvm
nvm --version
What should you see?
The first command should print nvm. The second should print 0.40.8.
That is the installer ready. The next check determines whether the required Node.js release is already active.
- Check the active Node.js version by running:
node --version
How does this check work?
The command prints the active Node.js version when one exists. It can also report that the command is unavailable because nvm has not installed a Node.js release yet.
- Choose the tab that matches your terminal result.
✔️ I see the required version
Your terminal shows v24.22.1. That exact Node.js release is already active.
ⓧ I see another version
nvm can install the required release without changing the copy managed elsewhere on your system.
- Install Node.js 24.22.1 by running:
nvm install 24.22.1
Node.js did not install?
This command downloads Node.js 24.22.1 through nvm before making it active in the current terminal. Check that nvm still responds if the installation stops.
Still stuck? Help me install Node.js through nvm.
ⓧ Command not found
nvm is ready, but it has not installed a Node.js runtime yet. The required release also includes the matching npm version.
- Install Node.js 24.22.1 by running:
nvm install 24.22.1
Node.js did not install?
This command downloads Node.js 24.22.1 through nvm before making it active in the current terminal. Check that nvm still responds if the installation stops.
Still stuck? Help me install my first Node.js version through nvm.
Before you run the final version check, predict the two version numbers you expect:
- Verify the active Node.js and npm versions by running:
node --version
npm --version
What should you see?
Node.js should print v24.22.1. npm should print 11.19.1.
That confirms your JavaScript runtime matches the versions used throughout this project.
Resume the saved Keycloak container
The stopped container still holds the realm, user, and client from Project 1. Starting that same container restores the lab without creating an empty replacement.
- Inspect the saved Keycloak container and its current status by running:
docker ps -a --filter ancestor=quay.io/keycloak/keycloak:26.8.1
What does this command show?
The command lists running and stopped containers created from the Keycloak 26.8.1 image. The STATUS column tells you whether the saved container needs to be started.
- Choose the tab that matches the container list.
✔️ The container is running
The row includes Up. Your saved Keycloak server is already running with its Project 1 data.
ⓧ The container is stopped
The row includes Exited. Starting this container keeps the existing realm, user, and client.
- Record the container ID from the row here: your Keycloak container ID.
- Replace <container-id> in the following command with your Keycloak container ID.
- Start the saved container by running:
docker start <container-id>
Container did not start?
This command starts the existing container identified by the value you copied. Recheck the container ID if Docker cannot find it.
Still stuck? Help me resume my saved Keycloak container.
ⓧ No container appears
Do not create a new Keycloak container. A new container would open an empty lab without the Project 1 state.
- Confirm Docker Engine is running on your Linux device.
- Confirm the Project 1 container was kept instead of deleted.
Cannot find the saved container?
The container may be hidden while Docker Engine is unavailable. It may also have been removed during Project 1 cleanup.
Still stuck? Help me recover access to my Project 1 Keycloak lab.
- Launch Firefox from your Linux applications menu.
- Enter http://localhost:8080/admin/ in the address bar.
- Use your saved Project 1 administrator credentials if Keycloak asks you to sign in.
You should see the Keycloak Admin Console. This proves the saved identity server is responding on local port 8080.
Admin Console not loading?
Return to the container list and confirm its status includes Up. Also check that no other local service is occupying port 8080.
Still stuck? Help me reach the local Keycloak Admin Console.
Scaffold and run the Vite app
A vanilla Vite scaffold gives you a small browser project without adding a component framework. Pinning its packages keeps your files aligned with the code used in later steps.
- Move to your Desktop by running this command:
cd ~/Desktop
Why start on the Desktop?
This places the new keycloak-sign-in-app folder somewhere easy to find. The command changes only your terminal location.
npm may ask for permission to download the scaffolder. Approve that request so it can create the project files.
- Create the pinned vanilla Vite project by running:
npm create vite@9.3.3 keycloak-sign-in-app -- --template vanilla
Project scaffold did not finish?
This command uses create-vite 9.3.3 to create the keycloak-sign-in-app folder from its vanilla template. Check your network connection if npm cannot download the scaffolder.
Still stuck? Help me scaffold the pinned vanilla Vite project.
- Move into the new project folder by running:
cd keycloak-sign-in-app
Where are you now?
Your terminal now works inside the keycloak-sign-in-app folder. Package commands run here update this project only.
- Confirm the scaffold files exist by running:
ls
What should the folder contain?
The output should include index.html, package.json, and the src directory. These are the files the next step transforms into your signed-out page.
- Install the scaffold dependencies and pin the identity packages by running:
npm install
npm install --save-exact keycloak-js@26.2.5
npm install --save-exact --save-dev vite@8.3.5
Packages did not install?
The first command installs the scaffold dependencies. The next commands save keycloak-js 26.2.5 and Vite 8.3.5 as exact versions.
Check that your terminal still uses Node.js v24.22.1 if npm reports a compatibility problem. Help me troubleshoot the pinned npm packages.
Compare your package file before starting the development server.
✔️ Awesome, I've got everything!
Your dependencies are pinned. Keep the terminal inside keycloak-sign-in-app for the development server.
ⓧ I'd like to double check the full code
{
"name": "keycloak-sign-in-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"keycloak-js": "26.2.5"
},
"devDependencies": {
"vite": "8.3.5"
}
}
How should this file compare?
The dependency entries should use exact version values without range prefixes. The scripts come from the pinned vanilla scaffold.
Before you start the development server, predict what address the terminal will provide:
- Start the Vite development server by running:
npm run dev
What does this command start?
The command runs Vite's development server for this project. It keeps the terminal occupied while the site is available at http://localhost:5173.
Keep this terminal open for the next step. Press Ctrl+C only when you want to stop the server.
- Return to Firefox from earlier.
- Enter http://localhost:5173 in the address bar.
You should see the Vite starter page. That visible page proves the local app server is running.
- Open a new Firefox tab.
- Enter http://localhost:8080/admin/ in the address bar.
You should still see the Keycloak Admin Console. Both local services are now available at the same time.
Vite starter page not loading?
Check that the terminal running npm run dev remains open. Confirm that its local address uses port 5173.
Still stuck? Help me get the Vite starter page running.
Your workshop is ready. Next, you'll replace the starter with a signed-out landing page that explains what your app protects.
Build the Signed-Out Landing Page
Your local Vite workshop is running. The starter page proves that your browser can load the project.
However, that page does not explain what the app protects. You will replace it with a signed-out view that makes its relationship with Keycloak clear.
In this step, get ready to:
- Replace the starter document with your app mount point.
- Render the signed-out state with JavaScript.
- Turn the page into a responsive interface with CSS.
Replace the starter structure
The HTML document gives your app a browser title. It also provides the mount point where JavaScript renders the interface.
- Return to the terminal running the Vite development server.
- Stop the development server by pressing Ctrl+C.
- Open index.html with nano by running this command:
nano index.html
- Replace all existing contents of index.html by pasting this document:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="A local Keycloak sign-in demonstration" />
<title>My Keycloak Sign-In App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
What does this structure do?
- The document metadata gives the page a readable browser title.
- The #app element provides an empty mount point for the interface.
- The module script loads src/main.js when the browser opens the page.
- Save index.html using nano's write control.
- Close nano using its exit control.
- Restart the Vite development server by running:
npm run dev
- Refresh the existing Firefox page at http://localhost:5173.
You should see My Keycloak Sign-In App in the browser tab. Good progress. Your custom document now owns the page.
Still seeing the old browser title?
- Confirm that nano saved the file as index.html inside keycloak-sign-in-app.
- Confirm that the Vite terminal still shows the local development address.
- Refresh Firefox after the development server finishes starting.
- Still stuck? Help me find why Vite is still showing the old page title.
Build the signed-out state
The mount point is ready for a visible app state. This first JavaScript version renders the landing page directly into the browser.
- Return to the terminal running the Vite development server.
- Stop the development server by pressing Ctrl+C.
- Open src/main.js with nano by running this command:
nano src/main.js
- Replace all existing contents of src/main.js by pasting this code:
import './style.css'
const app = document.querySelector('#app')
app.innerHTML = `
<main class="shell">
<section class="hero">
<p class="eyebrow">LOCAL IDENTITY LAB</p>
<h1>My Keycloak Sign-In App</h1>
<p class="intro">This app will send you to Keycloak for sign-in, so the app never handles your password.</p>
<div class="status status-out" id="status">
<span class="status-dot" aria-hidden="true"></span>
<div>
<strong>Signed out</strong>
<p>Use Keycloak to prove who you are.</p>
</div>
</div>
<button id="sign-in" class="primary-button" type="button">Sign in</button>
</section>
</main>
`
document.querySelector('#sign-in').addEventListener('click', () => {
document.querySelector('#status p').textContent = 'Sign in is not connected yet'
})
What does this code do?
- The stylesheet import connects the interface to src/style.css.
- The app reference selects the #app mount point from the document.
- The innerHTML assignment creates the signed-out landing page.
- The click listener changes the status message when the learner selects Sign in.
- Save src/main.js using nano's write control.
- Close nano using its exit control.
- Restart the Vite development server by running:
npm run dev
- Refresh the existing Firefox page at http://localhost:5173.
You should see the app name above a signed-out status panel. The page also shows a Sign in button.
Before you select Sign in, predict whether the status panel stays on the local page.
- Select Sign in.
You will remain on the local page. The status message changes to Sign in is not connected yet.
This shortfall is intentional
The button responds to your click. It has no identity connection yet.
You now have visible proof of the exact gap that the later Keycloak connection must close.
Does the status message stay unchanged?
- Confirm that the button still has the sign-in identifier.
- Confirm that the status panel still has the status identifier.
- Check the terminal for a JavaScript syntax error after saving src/main.js.
- Still stuck? Help me debug why the Sign in button does not update the status message.
Style the landing page
The interaction works, but the page still needs a clear visual hierarchy. The stylesheet will turn the signed-out state into a centered interface that remains readable on smaller screens.
- Return to the terminal running the Vite development server.
- Stop the development server by pressing Ctrl+C.
- Open src/style.css with nano by running this command:
nano src/style.css
- Replace all existing contents of src/style.css by pasting these page foundations:
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #e8eefc;
background: #081120;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: linear-gradient(145deg, #081120, #111c33 60%, #081120);
}
button { font: inherit; }
What do these foundations control?
- The root rules establish the typeface plus the page colors.
- The universal sizing rule keeps padding inside each element's declared width.
- The body rules give the page a full-height gradient background.
- The button rule keeps the control typography consistent with the page.
- Save src/style.css using nano's write control.
- Close nano using its exit control.
- Restart the Vite development server by running:
npm run dev
- Refresh the existing Firefox page at http://localhost:5173.
You should see light text over a dark blue gradient. The button now uses the same typeface as the page.
Still seeing the starter colors?
- Confirm that src/main.js begins with the stylesheet import.
- Confirm that you replaced the contents of src/style.css.
- Still stuck? Help me debug why my Vite page is not loading src/style.css.
- Return to the terminal running the Vite development server.
- Stop the development server by pressing Ctrl+C.
- Reopen src/style.css with nano by running this command:
nano src/style.css
- Move the cursor to the end of src/style.css.
- Add one blank line after the existing button rule.
- Append the centered layout by pasting this code:
.shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem 1rem;
}
.hero {
width: min(900px, 100%);
padding: clamp(1.5rem, 4vw, 3.5rem);
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 24px;
background: rgba(15, 23, 42, 0.88);
}
How does the layout adapt?
- The shell uses a grid to center the app panel within the viewport.
- The shell padding keeps the panel away from narrow screen edges.
- The hero width can shrink below its maximum size.
- The hero padding scales with the available viewport width.
- Save src/style.css using nano's write control.
- Close nano using its exit control.
- Restart the Vite development server by running:
npm run dev
- Refresh the existing Firefox page at http://localhost:5173.
You should see the app content centered inside a dark rounded panel. The panel should leave space around its edges.
Is the content still stuck in a corner?
- Confirm that the layout selectors begin with .shell and .hero.
- Confirm that the HTML uses the matching shell and hero class names.
- Still stuck? Help me debug why the shell and hero styles are not centering my landing page.
- Return to the terminal running the Vite development server.
- Stop the development server by pressing Ctrl+C.
- Reopen src/style.css with nano by running this command:
nano src/style.css
- Move the cursor to the end of src/style.css.
- Add one blank line after the hero rule.
- Append the component styles by pasting this code:
.eyebrow { color: #93c5fd; font-weight: 800; letter-spacing: 0.16em; }
h1 { font-size: clamp(2.2rem, 7vw, 4.7rem); }
.intro { color: #cbd5e1; line-height: 1.7; }
.status { display: flex; gap: 0.85rem; margin: 2rem 0; padding: 1rem; border-radius: 14px; }
.status-out { background: rgba(245, 158, 11, 0.12); border: 1px solid rgba(245, 158, 11, 0.42); }
.status-dot { width: 12px; height: 12px; margin-top: 0.35rem; border-radius: 50%; background: #f59e0b; }
.primary-button { min-height: 48px; padding: 0.75rem 1.25rem; border: 0; border-radius: 12px; background: #93c5fd; color: #06101f; font-weight: 800; cursor: pointer; }
.primary-button:focus-visible { outline: 3px solid #fbbf24; outline-offset: 3px; }
What do these component styles add?
- The text rules separate the label from the main app name.
- The status rules turn the signed-out message into an amber panel.
- The button rules create a large control with clear contrast.
- The focus rule gives keyboard users a visible outline around the button.
- Save src/style.css using nano's write control.
- Close nano using its exit control.
- Restart the Vite development server by running:
npm run dev
Before the final check, predict whether styling changes the button's temporary behavior.
- Refresh the existing Firefox page at http://localhost:5173.
- Select Sign in.
You should see the responsive signed-out landing page remain at the local address. Its status message should change to Sign in is not connected yet.
That is the visible limitation proven. Your page now looks ready for authentication while clearly showing that the identity journey still needs a connection.
Does the final page look incomplete?
- Confirm that the last stylesheet section includes the .status-out selector.
- Confirm that the button uses the primary-button class.
- Confirm that Firefox is displaying http://localhost:5173.
- Still stuck? Help me compare my landing page files with the completed signed-out version.
✔️ Awesome, I've got everything!
Great work. Confirm that all three edited files are saved while the Vite development server continues running.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="A local Keycloak sign-in demonstration" />
<title>My Keycloak Sign-In App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
{
"name": "keycloak-sign-in-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"keycloak-js": "26.2.5"
},
"devDependencies": {
"vite": "8.3.5"
}
}
import './style.css'
const app = document.querySelector('#app')
app.innerHTML = `
<main class="shell">
<section class="hero">
<p class="eyebrow">LOCAL IDENTITY LAB</p>
<h1>My Keycloak Sign-In App</h1>
<p class="intro">This app will send you to Keycloak for sign-in, so the app never handles your password.</p>
<div class="status status-out" id="status">
<span class="status-dot" aria-hidden="true"></span>
<div>
<strong>Signed out</strong>
<p>Use Keycloak to prove who you are.</p>
</div>
</div>
<button id="sign-in" class="primary-button" type="button">Sign in</button>
</section>
</main>
`
document.querySelector('#sign-in').addEventListener('click', () => {
document.querySelector('#status p').textContent = 'Sign in is not connected yet'
})
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #e8eefc;
background: #081120;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: linear-gradient(145deg, #081120, #111c33 60%, #081120);
}
button { font: inherit; }
.shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem 1rem;
}
.hero {
width: min(900px, 100%);
padding: clamp(1.5rem, 4vw, 3.5rem);
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 24px;
background: rgba(15, 23, 42, 0.88);
}
.eyebrow { color: #93c5fd; font-weight: 800; letter-spacing: 0.16em; }
h1 { font-size: clamp(2.2rem, 7vw, 4.7rem); }
.intro { color: #cbd5e1; line-height: 1.7; }
.status { display: flex; gap: 0.85rem; margin: 2rem 0; padding: 1rem; border-radius: 14px; }
.status-out { background: rgba(245, 158, 11, 0.12); border: 1px solid rgba(245, 158, 11, 0.42); }
.status-dot { width: 12px; height: 12px; margin-top: 0.35rem; border-radius: 50%; background: #f59e0b; }
.primary-button { min-height: 48px; padding: 0.75rem 1.25rem; border: 0; border-radius: 12px; background: #93c5fd; color: #06101f; font-weight: 800; cursor: pointer; }
.primary-button:focus-visible { outline: 3px solid #fbbf24; outline-offset: 3px; }
Your custom landing page now makes the signed-out state visible. Next, you will approve its local address in Keycloak so the real redirect journey can begin.
Approve the Local App in Keycloak
Your signed-out landing page now gives visitors a clear starting point. The Sign in button still stops locally because Keycloak trusts only Project 1's hosted test page.
Your existing myclient registration holds the approved addresses for this OpenID Connect app. You will extend that client for the local app.
The earlier hosted addresses must stay in place. This step changes Keycloak settings only, so your Vite files remain untouched.
In this step, get ready to:
- Open the existing public client without creating another one.
- Add the local login return address and web origin.
- Approve the logout return before verifying every saved value.
Open the existing client
The existing myclient registration already connects Project 1 to your identity server. Reusing it preserves that earlier setup.
- Return to the Firefox tab showing the Keycloak Admin Console at http://localhost:8080/admin/.
- Confirm the realm selector shows myrealm.
- Select Clients in the left sidebar.
- Select myclient from the clients table.
- Confirm Client authentication shows Off.
- Confirm Standard flow remains enabled.
Why keep this client public?
A public browser client runs in code downloaded by the browser. It cannot safely hold a client secret.
Keeping Client authentication set to Off matches this design. Standard flow lets Keycloak return an authorization code through the browser.
Append the local login settings
Approved return addresses control where Keycloak may send a sign-in result. Web origins identify which browser pages may communicate with Keycloak.
- Scroll to Login settings.
- Confirm Valid redirect URIs still contains https://www.keycloak.org/app/*.
- Add http://localhost:5173/ to Valid redirect URIs as another entry.
- Confirm Web origins still contains https://www.keycloak.org.
- Add http://localhost:5173 to Web origins as another entry.
You should now see one hosted value plus one local value in each field. Project 1 remains usable beside your local app.
Why are the local values different?
The redirect URI names the exact page Keycloak may return to after sign-in. Its value includes the trailing slash in http://localhost:5173/.
The web origin identifies the scheme plus the host plus the port. Its value is http://localhost:5173 without a trailing slash.
Approve the logout return
Keycloak also checks where the browser may return after logout. The + value tells this field to reuse the addresses already approved under Valid redirect URIs.
- Find Valid post logout redirect URIs under Login settings.
- Add + to Valid post logout redirect URIs.
- Select Save.
Before you recheck the saved client, do you expect the earlier hosted addresses to remain beside the new local addresses?
- Recheck Valid redirect URIs.
- Recheck Web origins.
- Recheck Valid post logout redirect URIs.
The Valid redirect URIs list contains https://www.keycloak.org/app/* plus http://localhost:5173/. The Web origins list contains https://www.keycloak.org plus http://localhost:5173.
The Valid post logout redirect URIs list contains +. You have now approved the complete local return journey without removing Project 1's addresses.
Settings not saving correctly?
- Check that http://localhost:5173/ includes its trailing slash in Valid redirect URIs.
- Check that http://localhost:5173 has no trailing slash in Web origins.
- Restore the hosted value in any field where it was accidentally replaced.
- Still stuck? Help me verify my Keycloak client login settings.
Keycloak now trusts the local app's login and logout return addresses. Next, you will connect the Sign in button to the real redirect journey.
Connect Sign In, Claims, and Sign Out
Your local addresses are now approved by Keycloak. Your Vite app can use that trust to begin a real browser sign-in journey.
The landing page still has a disconnected button. It also cannot show who returns from sign-in. In this step, the official browser adapter closes both gaps.
In this step, get ready to:
- Initialize the official browser adapter with the approved client settings.
- Render safe identity claims without exposing raw tokens.
- Connect sign-in and sign-out to the local return address.
Initialize the Keycloak adapter
The keycloak-js adapter manages the redirect journey between your app and the identity server. Its initialization result tells the app whether to render the signed-out screen or the signed-in screen.
Why use the official adapter?
The official adapter handles the Authorization Code flow. It also performs the PKCE exchange that adds a one-time proof during sign-in.
A manual implementation would require more protocol code in the browser. The adapter keeps this project focused on the identity result your app uses.
The existing index.html and package.json already match the finished app. The two source files below contain the new identity behavior and its signed-in styles.
- Use nano to replace src/main.js with the complete file in the matching tab below.
- Use nano to replace src/style.css with the complete file in the matching tab below.
src/main.js
import './style.css'
import Keycloak from 'keycloak-js'
const app = document.querySelector('#app')
const appUrl = `${window.location.origin}/`
const keycloak = new Keycloak({
url: 'http://localhost:8080',
realm: 'myrealm',
clientId: 'myclient',
})
function safeClaim(value) {
return typeof value === 'string' && value.trim() ? value : 'Not included'
}
function formatExpiry(expiry) {
return typeof expiry === 'number'
? new Date(expiry * 1000).toLocaleString()
: 'Not included'
}
function renderSignedOut(message = 'Use Keycloak to prove who you are.') {
app.innerHTML = `
<main class="shell">
<section class="hero">
<p class="eyebrow">LOCAL IDENTITY LAB</p>
<h1>My Keycloak Sign-In App</h1>
<p class="intro">
This app sends you to Keycloak for sign-in, so the app never handles
your password.
</p>
<div class="status status-out">
<span class="status-dot" aria-hidden="true"></span>
<div>
<strong>Signed out</strong>
<p>${message}</p>
</div>
</div>
<button id="sign-in" class="primary-button" type="button">Sign in</button>
</section>
</main>
`
document.querySelector('#sign-in').addEventListener('click', () => {
keycloak.login({ redirectUri: appUrl })
})
}
function renderSignedIn() {
const claims = keycloak.tokenParsed ?? {}
const roles = keycloak.resourceAccess?.myclient?.roles ?? []
app.innerHTML = `
<main class="shell">
<section class="hero">
<p class="eyebrow">LOCAL IDENTITY LAB</p>
<h1>Welcome, ${safeClaim(claims.name)}</h1>
<div class="status status-in">
<span class="status-dot" aria-hidden="true"></span>
<div>
<strong>Signed in</strong>
<p>Keycloak confirmed this browser session.</p>
</div>
</div>
<section class="card-grid" aria-label="Signed-in details">
<article class="card">
<h2>Username</h2>
<p>${safeClaim(claims.preferred_username)}</p>
</article>
<article class="card">
<h2>Email</h2>
<p>${safeClaim(claims.email)}</p>
</article>
<article class="card">
<h2>Client roles</h2>
<p>${roles.length ? roles.join(', ') : 'No client roles'}</p>
</article>
<article class="card">
<h2>Token expires</h2>
<p>${formatExpiry(claims.exp)}</p>
</article>
</section>
<p class="safety-note">
This page shows selected parsed details only. It never prints a raw token.
</p>
<button id="sign-out" class="secondary-button" type="button">Sign out</button>
</section>
</main>
`
document.querySelector('#sign-out').addEventListener('click', () => {
keycloak.logout({ redirectUri: appUrl })
})
}
async function startApp() {
try {
const authenticated = await keycloak.init({
flow: 'standard',
pkceMethod: 'S256',
checkLoginIframe: false,
})
if (authenticated) {
renderSignedIn()
} else {
renderSignedOut()
}
} catch (error) {
console.error('Keycloak initialization failed:', error)
renderSignedOut('The app could not reach Keycloak. Check that port 8080 is running.')
}
}
startApp()
What does this code do?
- The keycloak object points to the local identity server.
- Its configuration selects myrealm.
- Its configuration identifies the public client as myclient.
- The safeClaim() helper replaces missing text claims with a readable fallback.
- The formatExpiry() helper converts the expiry value into local time.
- The startApp() function chooses the correct screen after initialization.
src/style.css
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
color: #e8eefc;
background: #081120;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(49, 89, 180, 0.38), transparent 35%),
linear-gradient(145deg, #081120, #111c33 60%, #081120);
}
button {
font: inherit;
}
.shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem 1rem;
}
.hero {
width: min(900px, 100%);
padding: clamp(1.5rem, 4vw, 3.5rem);
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 24px;
background: rgba(15, 23, 42, 0.88);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.38);
}
.eyebrow {
margin: 0 0 0.75rem;
color: #93c5fd;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.16em;
}
h1 {
margin: 0;
font-size: clamp(2.2rem, 7vw, 4.7rem);
line-height: 1.02;
}
.intro {
max-width: 650px;
margin: 1.25rem 0 0;
color: #cbd5e1;
font-size: 1.08rem;
line-height: 1.7;
}
.status {
display: flex;
gap: 0.85rem;
align-items: flex-start;
margin: 2rem 0;
padding: 1rem;
border-radius: 14px;
}
.status p {
margin: 0.25rem 0 0;
color: #cbd5e1;
}
.status-dot {
width: 12px;
height: 12px;
margin-top: 0.35rem;
border-radius: 50%;
flex: 0 0 auto;
}
.status-out {
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.42);
}
.status-out .status-dot {
background: #f59e0b;
}
.status-in {
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.42);
}
.status-in .status-dot {
background: #22c55e;
}
.card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin: 2rem 0;
}
.card {
min-width: 0;
padding: 1.1rem;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 14px;
background: rgba(30, 41, 59, 0.72);
}
.card h2 {
margin: 0 0 0.55rem;
color: #93c5fd;
font-size: 0.82rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.card p {
margin: 0;
overflow-wrap: anywhere;
}
.safety-note {
margin: 0 0 1.25rem;
color: #94a3b8;
font-size: 0.92rem;
}
.primary-button,
.secondary-button {
min-height: 48px;
padding: 0.75rem 1.25rem;
border-radius: 12px;
border: 1px solid transparent;
cursor: pointer;
font-weight: 800;
}
.primary-button {
color: #06101f;
background: #93c5fd;
}
.secondary-button {
color: #e8eefc;
background: transparent;
border-color: #64748b;
}
.primary-button:hover,
.secondary-button:hover {
transform: translateY(-1px);
}
.primary-button:focus-visible,
.secondary-button:focus-visible {
outline: 3px solid #fbbf24;
outline-offset: 3px;
}
@media (max-width: 640px) {
.card-grid {
grid-template-columns: 1fr;
}
}
What does this styling add?
- The signed-in status uses a green treatment that separates it from the signed-out state.
- The card grid gives each safe identity detail its own readable area.
- The responsive rule stacks the cards on narrow screens.
- The focus outlines keep both controls visible during keyboard navigation.
- Save src/main.js in nano.
- Save src/style.css in nano.
- Return to Firefox at http://localhost:5173.
- Refresh the local app.
You'll see the signed-out landing page with an active Sign in button. The temporary disconnected message is gone.
Still seeing the disconnected message?
- Confirm that nano saved the replacement to src/main.js inside keycloak-sign-in-app.
- Confirm that the Vite development server from earlier is still running.
- Ask for help with the file replacement if the old click behavior remains. Help me check why my Vite app still runs the old Sign in handler.
Render safe signed-in details
A successful sign-in gives the adapter a parsed claims object. Your page selects readable identity details from that object while keeping complete access and refresh tokens out of the interface.
Which details reach the page?
- The welcome heading reads the user's name through safeClaim().
- The username card reads preferred_username.
- The email card shows a fallback when no email claim is present.
- The roles card reads the role list for myclient.
- The expiry card converts exp into a local date and time.
Before you select Sign in, what do you expect Firefox to visit before returning to your app?
- Select Sign in on the local app.
- Authenticate as myuser if Keycloak asks.
Firefox visits Keycloak before returning to http://localhost:5173/. You'll see a signed-in status with four information cards.
Did the redirect fail?
- Confirm that the Keycloak Admin Console from earlier still loads at http://localhost:8080/admin/.
- Confirm that the browser returns to the exact local root address.
- Ask for help if Firefox remains on Keycloak or returns to an error page. Help me troubleshoot my Keycloak sign-in redirect.
Connect the sign-in and sign-out controls
Both controls use appUrl as their return address. This keeps the login and logout journeys aligned with the local root that Keycloak already approves.
How do the controls work?
The signed-out renderer connects Sign in to keycloak.login(). The signed-in renderer connects Sign out to keycloak.logout().
Both calls send the same approved redirectUri. The browser therefore returns to the app after either journey.
Before you select Sign out, do you expect Firefox to stay on Keycloak or return to the local app?
- Select Sign out on the signed-in screen.
Keycloak ends the browser session before returning to the signed-out app at http://localhost:5173/.
- Select Sign in again.
- Authenticate as myuser if Keycloak asks.
You'll return to the signed-in status. You'll also see the username, email, client roles, expiry details, and Sign out button.
Sign out does not return to the app?
- Confirm that + remains in the client's approved post-logout return field.
- Confirm that http://localhost:5173/ remains in the approved redirect list.
- Ask for help if the logout journey stops before the local page returns. Help me troubleshoot my Keycloak logout return.
That is the core identity journey working. Next, you'll test reload behavior, quick SSO return, explicit logout, and the production build.
Prove the Full Browser Journey
Your Vite app now sends Firefox to Keycloak for sign-in. It returns with safe identity details plus a working sign-out control.
One successful screen cannot prove the full browser journey. A repeatable test shows how the app behaves across every session boundary.
You will test memory-only reload behavior. You will also prove single sign-on (SSO), explicit logout, and the production build.
In this step, get ready to:
- Test sign-in, reload, and quick SSO return.
- Prove explicit sign-out ends the Keycloak session.
- Create a successful Vite production build.
Test sign in and reload
The first journey proves that your app can leave its own origin for authentication. It must then return to the correct local address with a signed-in result.
- Return to http://localhost:5173/ in Firefox.
- Select Sign out if the app currently shows the signed-in view.
Before you select sign in, do you expect Firefox to remain on the local app or visit Keycloak first?
- Select Sign in.
- Authenticate as myuser if Keycloak asks for credentials.
Firefox visits Keycloak before returning to http://localhost:5173/. Your signed-in page shows the welcome message, four information cards, and the Sign out button.
That redirect loop is working. Your app can now delegate sign-in without handling the user's password.
The adapter keeps access and refresh tokens in memory. Reloading clears that app memory while the Keycloak browser session can remain active.
Before you reload, do you expect the app to keep showing the signed-in cards?
- Reload http://localhost:5173/ in Firefox.
You should see the signed-out landing page. The reload cleared the app's in-memory authentication state.
Before you select sign in again, do you expect Keycloak to ask for the password or reuse its active browser session?
- Select Sign in again.
You should return quickly to the signed-in app without entering the password again. This proves the Keycloak SSO session survived after the app lost its in-memory tokens.
Sign-in journey not matching the test?
- Confirm the local app still loads at http://localhost:5173/.
- Confirm the Keycloak Admin Console remains reachable at http://localhost:8080/admin/.
- Repeat the first sign-in before testing quick SSO return if the browser session has already expired.
- Still stuck? Help me diagnose my Keycloak redirect, reload, and SSO behavior.
Test explicit sign out
The quick return relied on a Keycloak session that still existed outside your app. Explicit logout ends that session at the identity server.
Before you sign out, do you expect the next sign-in attempt to reuse the current SSO session?
- Select Sign out on the signed-in page.
Keycloak returns Firefox to the signed-out app at http://localhost:5173/.
Before you select sign in again, do you expect to return immediately or see the Keycloak login page?
- Select Sign in once more.
You should see Keycloak require a fresh login. This proves explicit logout ended the saved browser session.
- Authenticate as myuser to complete the fresh sign-in test.
You should return to the signed-in app with the four information cards visible again.
Fresh login not required?
- Make sure you used the app's Sign out button before starting the next sign-in attempt.
- Confirm Firefox returned to the signed-out local page before selecting Sign in again.
- Still stuck? Help me verify that Keycloak logout ends my browser session.
Build the production app
The browser checks prove the development version works. A production build confirms Vite can bundle the same application into deployable files.
- Switch back to the project terminal from earlier.
- Stop the development server by pressing Ctrl+C.
Before you build, do you expect the same code that passed the browser journey to compile successfully?
- Create the production files by running this command:
npm run build
What does this command do?
- The build script in package.json starts Vite's production bundler.
- Vite processes your HTML, JavaScript, CSS, and installed dependencies.
- The completed files are written to the dist directory.
- Check that the command returns to the terminal prompt without a build failure.
- Confirm the completed build output lists generated paths inside dist.
You should see a successful build summary. The dist directory now contains the production version of your app.
Production build failing?
- Confirm the terminal is inside the keycloak-sign-in-app folder.
- Check that package.json still contains the build script.
- Read the first reported file path to identify which project file stopped the bundle.
- Still stuck? Help me diagnose my Vite production build failure.
That's the full browser journey proven. Your app now passes sign-in, reload, quick SSO return, explicit logout, fresh login, and production-build checks.
Secret mission
Show an Admin Badge from a Client Role
Your app already displays the roles in a signed-in token. In this Secret Mission, you will turn the `app-admin` client role into an Admin badge that appears only when the current token contains that permission.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep your resources running, pause them for later, or delete them entirely. Every resource runs locally. There are no ongoing service charges.
Resources you used:
- A local Vite project in the keycloak-sign-in-app folder, including its node_modules and dist directories.
- The existing Keycloak Server 26.8.1 Docker container, including myrealm, myuser, myclient, and the app-admin role.
Keep everything running
No action needed. Choose this if you want the browser demo ready for another sign-in journey.
- Leave the Vite development server running to keep the app available at http://localhost:5173.
- Leave the Keycloak container running to keep the Admin Console available at http://localhost:8080/admin/.
- Keep the keycloak-sign-in-app folder for future changes.
- Expect local memory usage while both processes remain active.
Pause - I'll come back to this later
Stop the running processes to free local memory. Your application files and Keycloak identity data remain available for later.
- Return to the project terminal from earlier.
- Press Ctrl+C if the Vite development server is running.
- Find the existing Keycloak container by running this command:
docker ps -a --filter ancestor=quay.io/keycloak/keycloak:26.8.1
What Does This Check?
This lists containers created from the Keycloak image. Use the row for the existing Project 1 container.
- Copy the container ID from the Keycloak row.
- Stop the container by replacing <container-id> with the copied ID in this command:
docker stop <container-id>
What Does Stopping Preserve?
Stopping the container releases its running resources. The saved realm, user, client settings, and client role remain inside the container.
Your workshop is paused. Restart the same container when you want to continue so its identity data returns with it.
Delete - I don't want to use this again
Remove the local application and its Keycloak container when you no longer need this lab. Deleting the container permanently removes the identity state shared by Project 1 and this project.
- Return to the project terminal from earlier.
- Press Ctrl+C if the Vite development server is running.
- Find the existing Keycloak container by running this command:
docker ps -a --filter ancestor=quay.io/keycloak/keycloak:26.8.1
What Does This Check?
This identifies the container that holds your saved Keycloak state. The container ID lets the next commands target the correct resource.
- Copy the container ID from the Keycloak row.
- Stop the container by replacing <container-id> with the copied ID in this command:
docker stop <container-id>
Why Stop It First?
The container must be stopped before this cleanup path removes it. Docker confirms which container stopped.
- Permanently remove the stopped container by using the same ID in this command:
docker rm <container-id>
What Does Removal Delete?
This permanently removes the container that stores myrealm, myuser, myclient, and app-admin. Docker confirms the removal after it completes.
- Move from the keycloak-sign-in-app folder to its parent folder by running this command:
cd ..
Why Move Up One Folder?
The parent folder contains keycloak-sign-in-app as a removable directory. This position lets the next command target the whole project.
- Permanently remove the local application folder by running this command:
rm -rf keycloak-sign-in-app
What Does This Remove?
This deletes the source files and installed packages. It also deletes the generated dist directory.
- Confirm the project folder is gone by listing the remaining folders:
ls
What Should You See?
The output should no longer include keycloak-sign-in-app. Your local application and Keycloak container are now removed.
Nice Work!
Nice Work!
You did it! Your custom Vite sign-in app now sends Firefox to Keycloak for myuser sign-in. It returns with safe identity details without handling a password or displaying raw tokens.
You've learned how to:
- Reuse an existing identity server through myrealm, myuser, and myclient. Preserve the earlier approved addresses while adding exact local return settings.
- Connect the official browser adapter through the Authorization Code flow with PKCE. Keep tokens in memory while rendering only parsed claims, client roles, and expiry.
- Prove the full browser journey through redirect, reload, quick SSO return, explicit sign-out, and fresh authentication. Confirm the production build by creating the dist directory.
- Complete the optional Secret Mission by using the app-admin client role to control an Admin badge. Keep backend authorization separate from browser visibility.
Ready to quiz yourself?