Welcome to this beginner’s guide to creating a React app using Vite! integrating OAuth/OpenID Authentication with a Node.js Express server at localhost:3000
. This tutorial explains a React application that integrates with Keycloak, an open-source identity and access management solution, using the oidc-client-ts library for OpenID Connect (OIDC) authentication. The code demonstrates how to set up user authentication, handle login and logout, and make authenticated API calls. Below, I’ll walk through the key components and functionality of the code, explaining each part in detail.
What You Need
- Node.js installed (v18.x or later, like v23.10.0 as of June 23, 2025) from nodejs.org.
- A text editor like VS Code.
- Basic JavaScript knowledge (functions and variables).
- An understanding of the key principles of the OAuth Code Flow (see: Key Principles of modern Authentication in Web Applications)
Set Up Keycloak
Open Terminal and run
docker run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:latest start-dev
Access Keycloak at http://localhost:8080 and log in with admin/admin. For Windows users, use PowerShell or Command Prompt, install Docker Desktop from docker.com and ensure WSL 2 is enabled, check port 8080 with
netstat -aon | findstr :8080
and allow it in Windows Defender Firewall if blocked. Go to the Master realm, click Create Realm, name it my-realm, and save. Navigate to Clients and select Create. Set Client ID to my-app and Client Protocol to openid-connect, and Access Type to public. Save, then configure Valid Redirect URIs to http://localhost:4200/* and Web Origins to +. For Client Authentication, set to Off as default for public clients like React as it doesn’t require a client secret. For Authentication Flow, use Standard Flow, which supports Authorization Code Flow with PKCE, ideal for our React client. Go to Users and select Add User. Set username to testuser, save, and go to the Credentials tab. Set a password to password and disable Temporary. Open http://localhost:8080/realms/my-realm/account and log in to verify.
Set Up the React-Client Project with Vite
Run this command to create a new Vite React app:
npm create vite@latest
Move into the project folder:
cd react-vite-auth
Install the dependencies:
npm install
Start the development server:
npm run dev
Open http://localhost:5173 in your browser to see the default app.
Implementing the client
Vite creates a standard page src/App.jsx which we are going to delete and replace with our code in the following. Our code begins with imports from the React library and the oidc-client-ts package.
import { useEffect, useState } from 'react';
import { UserManager } from 'oidc-client-ts';
The useEffect and useState hooks are imported from React to manage side effects and state in the functional component. The UserManager class from oidc-client-ts is used to handle OIDC authentication flows, such as logging in, logging out, and managing user sessions.
Next, a UserManager instance is created with a configuration object.
const userManager = new UserManager({
authority: 'http://localhost:8080/realms/my-realm',
client_id: 'my-app',
redirect_uri: 'http://localhost:5173/callback',
response_type: 'code',
scope: 'openid profile email',
automaticSilentRenew: false,
});
The UserManager is initialized with settings for the OIDC client. The authority specifies the Keycloak server’s URL, pointing to a specific realm (my-realm) where authentication is managed. The client_id identifies the application in Keycloak, and redirect_uri defines where the user is redirected after authentication (in this case, a /callback route on the React app running at http://localhost:5173). The response_type: ‘code’ indicates the use of the Authorization Code Flow, a secure method for obtaining an access token. The scope requests access to the user’s identity (openid), profile information (profile), and email (email). The automaticSilentRenew: false disables automatic token renewal, requiring manual handling of token refreshes.
The App component is defined as the main React component.
function App() {
const [user, setUser] = useState(null);
const [apiResponse, setApiResponse] = useState(null);
Inside the App component, two state variables are created using the useState hook. The user state holds the authenticated user’s information (or null if no user is logged in), and apiResponse stores the response from a protected API call, initially set to null.
The first useEffect hook checks for an existing user session when the component mounts.
useEffect(() => {
userManager.getUser().then((user) => {
if (user) {
setUser(user);
}
});
}, []);
The useEffect hook runs once when the component mounts, as indicated by the empty dependency array ([]). The userManager.getUser() method attempts to retrieve a cached user session from the browser’s storage (e.g., localStorage or sessionStorage). If a valid user session exists, the user state is updated with the user object, which contains details like the access token and user profile. This ensures that if a user is already logged in (e.g., after a page refresh), the app recognizes the authenticated state.
The login function initiates the authentication process.
const login = () => {
userManager.signinRedirect();
};
The login function calls userManager.signinRedirect(), which redirects the user to the Keycloak login page. This starts the OIDC Authorization Code Flow, where the user authenticates with their credentials, and Keycloak redirects them back to the redirect_uri (/callback) with an authorization code.
The logout function handles user sign-out.
const logout = () => {
userManager.signoutRedirect();
};
The logout function invokes userManager.signoutRedirect(), which redirects the user to Keycloak’s logout endpoint, terminating the session. After logout, Keycloak typically redirects the user back to the application or a specified logout redirect URI.
The fetchProtected function makes an authenticated API call to a protected endpoint.
const fetchProtected = async () => {
if (user) {
try {
const response = await fetch('http://localhost:3000/protected', {
headers: {
Authorization: `Bearer ${user.access_token}`,
},
});
const data = await response.json();
setApiResponse(data);
} catch (error) {
console.error('Error fetching protected route:', error);
}
}
};
The fetchProtected function checks if a user exists (i.e., the user is authenticated). If so, it sends a fetch request to a protected API endpoint at http://localhost:3000/protected. The request includes an Authorization header with the user’s access token prefixed by Bearer. The access token, obtained during authentication, is stored in the user.access_token property. The response is parsed as JSON, and the apiResponse state is updated with the data. If an error occurs (e.g., the token is invalid or the API is unreachable), it’s caught and logged to the console.
A second useEffect hook handles the callback after a successful login.
useEffect(() => {
if (window.location.pathname === '/callback') {
userManager.signinRedirectCallback().then((user) => {
setUser(user);
window.history.replaceState({}, document.title, '/');
});
}
}, []);
This useEffect hook checks if the current URL path is /callback, which is the route Keycloak redirects to after authentication. If true, userManager.signinRedirectCallback() is called to process the authorization code and exchange it for an access token, ID token, and other user data. The resulting user object is stored in the user state. To ensure a clean user experience, the URL is updated using window.history.replaceState() to remove the /callback path and query parameters, redirecting the user to the root route (/). This hook runs only once on component mount due to the empty dependency array.
The component’s JSX defines the user interface.
return (
<div className="p-4">
<h1 className="text-2xl mb-4">Keycloak React App</h1>
{user ? (
<div>
<p>Welcome, {user.profile.preferred_username}!</p>
<button
className="bg-red-500 text-white px-4 py-2 rounded mr-2"
onClick={logout}
>
Logout
</button>
<button
className="bg-blue-500 text-white px-4 py-2 rounded"
onClick={fetchProtected}
>
Call Protected API
</button>
{apiResponse && (
<pre className="mt-4 p-4 bg-gray-100 rounded">
{JSON.stringify(apiResponse, null, 2)}
</pre>
)}
</div>
) : (
<button
className="bg-green-500 text-white px-4 py-2 rounded"
onClick={login}
>
Login
</button>
)}
</div>
);
The JSX renders a simple interface with Tailwind CSS classes for styling. The outer <div> provides padding, and an <h1> displays the app’s title. The rendering is conditional based on the user state. If a user is authenticated (user is not null), the app shows a welcome message with the user’s username (user.profile.preferred_username), a “Logout” button styled in red, and a “Call Protected API” button styled in blue. If an API response exists (apiResponse is not null), it’s displayed in a formatted <pre> block using JSON.stringify for readability. If no user is authenticated, a green “Login” button is shown, which triggers the login function when clicked.
Finally, the component is exported as the default export.
<code>export default App;</code>
This allows the App component to be imported and used as the main entry point in a React application, typically rendered in a root file like index.js.
Implementing the server
Let’s walk through the provided Express server code, breaking down each key component to explain its purpose and functionality in a clear, narrative style. The code sets up a Node.js server using Express, integrates JSON Web Token (JWT) authentication with a JSON Web Key Set (JWKS) for secure key management, and includes middleware for handling requests. I’ll explain each section of the code, focusing on its role and how it contributes to the server’s operation, with code snippets to highlight the relevant parts.
The code begins with importing necessary dependencies. The express module is the core framework for building the server, providing tools to handle HTTP requests and responses. The Request, Response, and NextFunction types from Express are imported to enable TypeScript type safety for request handling. The jsonwebtoken library (aliased as jwt) is used for JWT creation, decoding, and verification, with JwtPayload providing a type for the decoded token’s payload. The jwks-rsa library facilitates fetching and caching public keys from a JWKS endpoint, crucial for verifying JWT signatures. The cors middleware enables Cross-Origin Resource Sharing, allowing the server to accept requests from different origins.
import express, { Request, Response, NextFunction } from 'express';
import jwt, { JwtPayload } from 'jsonwebtoken';
import jwksRsa from 'jwks-rsa';
import cors from 'cors';
Next, the code extends the Express Request interface to include an optional user property, which can hold either a JwtPayload or a string. This extension allows the server to attach verified JWT claims to the request object, making them accessible in subsequent middleware or route handlers. This is a TypeScript-specific enhancement to ensure type safety when accessing req.user later.
interface AuthRequest extends Request {
user?: JwtPayload | string;
}
The server is initialized by creating an Express application instance and defining the port it will listen on, set to 3000. This is the foundation of the server, where all routes and middleware will be attached.
const app = express();
const port = 3000;
The JWKS client is configured using jwksRsa to interact with a JWKS endpoint at http://localhost:8080/realms/my-realm/protocol/openid-connect/certs. This endpoint, typically provided by an identity provider like Keycloak, hosts public keys for verifying JWT signatures. The configuration enables caching to reduce repeated requests to the JWKS endpoint, sets rate limiting to avoid overwhelming the endpoint, and limits requests to five per minute. This setup ensures efficient and secure key retrieval for token verification.
const jwksClient = jwksRsa({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: 'http://localhost:8080/realms/my-realm/protocol/openid-connect/certs'
});
The server uses two middleware functions globally. The cors middleware allows cross-origin requests, which is essential for APIs accessed by front-end applications hosted on different domains. The express.json middleware parses incoming request bodies as JSON, enabling the server to handle JSON payloads in POST or PUT requests.
app.use(cors());
app.use(express.json());
A utility function, getKey, is defined to retrieve the public key for a given JWT’s key ID (kid). The function takes a JWT header and a callback as arguments. It uses the JWKS client to fetch the signing key associated with the kid from the JWKS endpoint. If successful, it returns the public key via the callback; otherwise, it passes an error. This function is critical for dynamic key retrieval, as JWTs are signed with specific keys identified by their kid.
function getKey(header: jwt.JwtHeader, callback: (err: Error | null, key?: string) => void): void {
jwksClient.getSigningKey(header.kid, (err, key) => {
if (err) {
callback(err);
} else {
const signingKey = key?.getPublicKey();
callback(null, signingKey);
}
});
}
The validateJwt middleware is the core of the server’s authentication logic. It checks for a JWT in the Authorization header of incoming requests, expecting the format Bearer <token>. If the header is missing or incorrectly formatted, it returns a 401 status with an error message. The token is extracted by splitting the header string. The middleware then decodes the token using jwt.decode to inspect its structure without verifying it, ensuring it’s valid before proceeding. If decoding fails, a 401 error is returned. For verification, the middleware specifies options: it expects the RS256 algorithm, checks the issuer against the identity provider’s URL, and ensures the token hasn’t expired. The jwt.verify function uses the getKey function to fetch the appropriate public key and verify the token’s signature. If verification succeeds, the decoded token’s claims are attached to req.user, and the request proceeds to the next middleware or route. If verification fails, a 401 error with details is returned. Any unexpected errors during processing also result in a 401 response.
const validateJwt = async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'No token provided' });
return;
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.decode(token, { complete: true }) as jwt.Jwt | null;
if (!decoded) {
res.status(401).json({ error: 'Invalid token' });
return;
}
const options: jwt.VerifyOptions = {
algorithms: ['RS256'],
issuer: 'http://localhost:8080/realms/my-realm',
ignoreExpiration: false
};
jwt.verify(token, getKey, options, (err, verified) => {
if (err) {
res.status(401).json({ error: 'Token verification failed', details: err.message });
return;
}
req.user = verified;
next();
});
} catch (error) {
res.status(401).json({ error: 'Token processing error', details: (error as Error).message });
}
};
The server defines a protected route at /protected, which requires JWT authentication via the validateJwt middleware. If the token is valid, the route returns a JSON response with a success message and the user’s claims from req.user. This demonstrates how authenticated routes can access user information from the verified token.
app.get('/protected', validateJwt, async (req: AuthRequest, res: Response) => {
res.json(
{ message: 'Protected endpoint. Login successfull', user: req.user }
);
});
A public route at / is also defined, accessible without authentication. It returns a simple JSON message, showing how the server can handle both public and protected endpoints.
app.get('/', (req: Request, res: Response) => {
res.json({ message: 'Public endpoint' });
});
An error-handling middleware catches any unhandled errors in the application, logging the error stack to the console and returning a 500 status with a generic error message. This ensures the server doesn’t crash on unexpected errors and provides a consistent response to clients.
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal server error' });
});
Finally, the server is started using app.listen, binding it to port 3000. A console message confirms the server is running, providing the URL for access. This completes the setup, making the server ready to handle requests.
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
In summary, this Express server implements a secure API with JWT-based authentication, leveraging JWKS for dynamic key management. It includes middleware for parsing JSON and enabling CORS, a custom middleware for token validation, and both protected and public routes. The error-handling middleware ensures robustness, and the server runs on a specified port, ready to handle authenticated and unauthenticated requests. Each component is carefully integrated to provide a secure and functional API.
Key Points Summary
The code integrates Keycloak authentication into a React application using the oidc-client-ts library, while the Express server provides a secure backend with JWT-based authentication. The UserManager in the React app handles OIDC flows, including login, logout, and token management, ensuring seamless authentication with a Keycloak server running at http://localhost:8080. The Express server, running on port 3000, validates JWTs using the jwks-rsa library to fetch public keys dynamically from a JWKS endpoint at http://localhost:8080/realms/my-realm/protocol/openid-connect/certs, supporting secure token verification with caching and rate limiting. The server employs middleware to parse JSON request bodies and enable CORS for cross-origin requests from the React app. A custom validateJwt middleware checks for a valid Bearer token in the Authorization header, verifies its signature, issuer, and expiration, and attaches the verified claims to the request object for protected routes. The /protected endpoint requires a valid JWT to access user data, while the / endpoint is publicly accessible. Error-handling middleware catches unhandled errors, ensuring robust server operation. Both the client and server assume a Keycloak realm named my-realm, with the client’s redirect_uri and client_id configured to match Keycloak’s settings for proper authentication flow. The server’s configuration ensures secure API access, with the validateJwt middleware enforcing authentication for protected routes, while the JWKS client efficiently manages key retrieval for token verification.