Skip to main content

React (Vite) + Express.js Integration

Integrate ConsentKeys into a React SPA (Vite) with an Express.js backend that handles the OAuth callback, token exchange, and server-side sessions.

Why this architecture?

  • React is a public client: your client secret must never be shipped to the browser.
  • Express handles OAuth securely: PKCE + state stored in a server session, tokens exchanged server-side.
  • Your SPA gets a session cookie: the browser only talks to your API (/auth/me, /auth/logout, etc.).

Prerequisites

  • A ConsentKeys Client ID + Secret from the Developer Portal
  • Node.js 18+
  • React 18+ (Vite)
  • Express.js 4+
Redirect URI configuration

Register the redirect URI to your Express backend callback (not your Vite dev server), e.g.:

  • Local: http://localhost:4000/auth/callback
  • Production: https://api.yourapp.com/auth/callback

Architecture

Backend (Express): OAuth + session

Step 1: Install dependencies

npm install express express-session cors dotenv

Optional (recommended in production):

npm install helmet

Step 2: Environment variables

.env
# Your frontend URL (where users end up after auth)
APP_URL=http://localhost:5173

# Your API URL (this Express server)
API_URL=http://localhost:4000

# ConsentKeys issuer
CONSENTKEYS_ISSUER=https://api.consentkeys.com

# OAuth client credentials (server-only)
CONSENTKEYS_CLIENT_ID=ck_your_client_id
CONSENTKEYS_CLIENT_SECRET=your_client_secret

# Session secret (32+ chars)
SESSION_SECRET=replace-with-a-long-random-secret

Step 3: Express server

server.ts
import "dotenv/config";
import crypto from "crypto";
import express from "express";
import session from "express-session";
import cors from "cors";

const app = express();

const APP_URL = process.env.APP_URL!;
const API_URL = process.env.API_URL!;
const CONSENTKEYS_ISSUER = process.env.CONSENTKEYS_ISSUER ?? "https://api.consentkeys.com";
const CLIENT_ID = process.env.CONSENTKEYS_CLIENT_ID!;
const CLIENT_SECRET = process.env.CONSENTKEYS_CLIENT_SECRET!;
const REDIRECT_URI = `${API_URL}/auth/callback`;

app.set("trust proxy", 1);
app.use(express.json());
app.use(
cors({
origin: APP_URL,
credentials: true,
})
);

app.use(
session({
name: "ck_session",
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
},
})
);

type SessionUser = {
sub: string;
email?: string;
name?: string;
picture?: string;
};

function base64Url(buf: Buffer) {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}

function pkceVerifier() {
return base64Url(crypto.randomBytes(32));
}

function pkceChallenge(verifier: string) {
const hash = crypto.createHash("sha256").update(verifier).digest();
return base64Url(hash);
}

function randomState() {
return base64Url(crypto.randomBytes(16));
}

app.get("/auth/login", async (req, res) => {
const verifier = pkceVerifier();
const challenge = pkceChallenge(verifier);
const state = randomState();

// Store for callback validation + token exchange
(req.session as any).pkceVerifier = verifier;
(req.session as any).oauthState = state;

const params = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: "openid profile email",
state,
code_challenge: challenge,
code_challenge_method: "S256",
});

res.redirect(`${CONSENTKEYS_ISSUER}/auth?${params.toString()}`);
});

app.get("/auth/callback", async (req, res) => {
const { code, state, error, error_description } = req.query as Record<string, string | undefined>;

if (error) {
return res.redirect(`${APP_URL}/?error=${encodeURIComponent(error_description ?? error)}`);
}

if (!code || !state) {
return res.redirect(`${APP_URL}/?error=${encodeURIComponent("missing_code_or_state")}`);
}

const expectedState = (req.session as any).oauthState as string | undefined;
const verifier = (req.session as any).pkceVerifier as string | undefined;

if (!expectedState || state !== expectedState) {
return res.redirect(`${APP_URL}/?error=${encodeURIComponent("invalid_state")}`);
}
if (!verifier) {
return res.redirect(`${APP_URL}/?error=${encodeURIComponent("missing_pkce_verifier")}`);
}

// Exchange code for tokens
const tokenResp = await fetch(`${CONSENTKEYS_ISSUER}/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code_verifier: verifier,
}),
});

if (!tokenResp.ok) {
const text = await tokenResp.text();
return res.redirect(`${APP_URL}/?error=${encodeURIComponent(`token_exchange_failed:${text}`)}`);
}

const tokens = (await tokenResp.json()) as { access_token: string; id_token?: string };

// Fetch user profile
const userinfoResp = await fetch(`${CONSENTKEYS_ISSUER}/userinfo`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});

if (!userinfoResp.ok) {
const text = await userinfoResp.text();
return res.redirect(`${APP_URL}/?error=${encodeURIComponent(`userinfo_failed:${text}`)}`);
}

const user = (await userinfoResp.json()) as SessionUser;

(req.session as any).user = user;
(req.session as any).accessToken = tokens.access_token;

// Clear one-time OAuth values
delete (req.session as any).pkceVerifier;
delete (req.session as any).oauthState;

return res.redirect(`${APP_URL}/dashboard`);
});

app.get("/auth/me", (req, res) => {
const user = (req.session as any).user as SessionUser | undefined;
if (!user) return res.status(401).json({ error: "not_authenticated" });
res.json({ user });
});

app.post("/auth/logout", (req, res) => {
req.session.destroy(() => {
res.clearCookie("ck_session");
res.json({ success: true });
});
});

app.listen(4000, () => {
console.log("API listening on http://localhost:4000");
});
Production note

Use a real session store (Redis, database) instead of the default in-memory store, and run behind HTTPS so secure: true cookies can be used.

Frontend (React + Vite): login + session fetch

Step 1: Login button

Your SPA should initiate login by sending the browser to your backend (so the backend can set session + PKCE state):

src/components/LoginButton.tsx
export function LoginButton() {
return (
<a href="http://localhost:4000/auth/login">
<button>Sign in with ConsentKeys</button>
</a>
);
}

Step 2: Load the session user

src/hooks/useMe.ts
import { useEffect, useState } from "react";

type User = { sub: string; email?: string; name?: string; picture?: string };

export function useMe() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
fetch("http://localhost:4000/auth/me", { credentials: "include" })
.then((r) => (r.ok ? r.json() : null))
.then((data) => setUser(data?.user ?? null))
.finally(() => setLoading(false));
}, []);

return { user, loading, isAuthenticated: !!user };
}

Step 3: Logout

src/components/LogoutButton.tsx
export function LogoutButton() {
const logout = async () => {
await fetch("http://localhost:4000/auth/logout", {
method: "POST",
credentials: "include",
});
window.location.href = "/";
};

return <button onClick={logout}>Logout</button>;
}

Local testing checklist

  1. Start the ConsentKeys stack (or use the hosted issuer https://api.consentkeys.com)
  2. Start Express on http://localhost:4000
  3. Start Vite on http://localhost:5173
  4. Click “Sign in with ConsentKeys” → complete magic link → you should land on /dashboard
  5. Refresh the page → you should still be logged in (session cookie)

Common issues

  • Redirect URI mismatch: ensure http://localhost:4000/auth/callback matches exactly in the Developer Portal.
  • CORS / cookies not sent: you must use credentials: "include" on frontend fetch, and cors({ credentials: true, origin: APP_URL }) on the backend.
  • State mismatch: your Express session cookie must survive the round-trip; verify cookies are being set and not blocked by browser settings.