Server-Side Web App Integration
Server-side web applications (Node.js, Python, Ruby, PHP, Java, etc.) run on infrastructure you control. They are confidential clients — they can securely store a client_secret and use it during the token exchange for an additional layer of security.
This guide shows a complete integration using Node.js with Express. The same concepts apply to any server framework.
How the Flow Differs from SPA
| SPA (Public Client) | Server App (Confidential Client) | |
|---|---|---|
| Client secret | Not used | Sent with token request |
| PKCE | Required | Recommended (defense in depth) |
| Token storage | Browser sessionStorage | Server-side session / encrypted cookie |
| Token exchange | Browser → SingleSign | Server → SingleSign (server-to-server) |
Prerequisites
- A registered SingleSign app with Application Type set to "Server Web Application."
- Your
client_id,client_secret, andplatform_guid. - Node.js 18+ and Express installed.
npm install express express-session crypto
Step 1: Configuration
// config.ts
export const singleSignConfig = {
clientId: process.env.SINGLESIGN_CLIENT_ID!,
clientSecret: process.env.SINGLESIGN_CLIENT_SECRET!,
platformGuid: process.env.SINGLESIGN_PLATFORM_GUID!,
redirectUri: "https://app.example.com/auth/callback",
scopes: "openid profile email",
authorizationEndpoint: "https://api.singlesign.com/oauth/authorize",
tokenEndpoint: "https://api.singlesign.com/oauth/token",
userinfoEndpoint: "https://api.singlesign.com/oauth/userinfo",
};
Never hard-code your client secret. Use environment variables or a secrets manager.
Step 2: PKCE Helpers
Even though confidential clients have a secret, we recommend PKCE for defense in depth.
// pkce.ts
import crypto from "crypto";
export function generateCodeVerifier(): string {
return crypto.randomBytes(32).toString("base64url");
}
export function generateCodeChallenge(verifier: string): string {
return crypto.createHash("sha256").update(verifier).digest("base64url");
}
export function generateState(): string {
return crypto.randomBytes(16).toString("base64url");
}
Step 3: Express Application
// app.ts
import express from "express";
import session from "express-session";
import { singleSignConfig } from "./config";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "./pkce";
const app = express();
app.use(
session({
secret: process.env.SESSION_SECRET || "change-me-in-production",
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "lax",
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
})
);
// ─── Login Route ────────────────────────────────────────────
app.get("/auth/login", (req, res) => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();
// Store in server-side session
req.session.codeVerifier = codeVerifier;
req.session.oauthState = state;
const params = new URLSearchParams({
platform_guid: singleSignConfig.platformGuid,
response_type: "code",
client_id: singleSignConfig.clientId,
redirect_uri: singleSignConfig.redirectUri,
scope: singleSignConfig.scopes,
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
res.redirect(`${singleSignConfig.authorizationEndpoint}?${params}`);
});
// ─── Callback Route ─────────────────────────────────────────
app.get("/auth/callback", async (req, res) => {
const { code, state, error, error_description } = req.query;
// Handle authorization errors
if (error) {
return res.status(400).send(`Authorization error: ${error} — ${error_description}`);
}
// Verify state
if (state !== req.session.oauthState) {
return res.status(403).send("State mismatch — possible CSRF attack.");
}
const codeVerifier = req.session.codeVerifier;
// Exchange authorization code for tokens (server-to-server)
const tokenResponse = await fetch(singleSignConfig.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: code as string,
redirect_uri: singleSignConfig.redirectUri,
client_id: singleSignConfig.clientId,
client_secret: singleSignConfig.clientSecret,
code_verifier: codeVerifier,
platform_guid: singleSignConfig.platformGuid,
}),
});
if (!tokenResponse.ok) {
const err = await tokenResponse.json();
return res.status(400).send(`Token exchange failed: ${err.error_description}`);
}
const tokens = await tokenResponse.json();
// Fetch user info
const userinfoResponse = await fetch(singleSignConfig.userinfoEndpoint, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const userInfo = await userinfoResponse.json();
// Store user info in the session
req.session.user = {
humanGuid: userInfo.human_guid,
name: userInfo.name,
email: userInfo.email,
};
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
// Clean up PKCE/state from session
delete req.session.codeVerifier;
delete req.session.oauthState;
res.redirect("/dashboard");
});
// ─── Protected Route ────────────────────────────────────────
app.get("/dashboard", (req, res) => {
if (!req.session.user) {
return res.redirect("/auth/login");
}
res.send(`
<h1>Welcome, ${req.session.user.name}!</h1>
<p>Email: ${req.session.user.email}</p>
<p>Human GUID: ${req.session.user.humanGuid}</p>
<a href="/auth/logout">Log out</a>
`);
});
// ─── Logout Route ───────────────────────────────────────────
app.get("/auth/logout", (req, res) => {
req.session.destroy(() => {
res.redirect("/");
});
});
// ─── Start Server ───────────────────────────────────────────
app.listen(3000, () => {
console.log("Server running at http://localhost:3000");
});
Step 4: Token Refresh
For long-lived sessions, refresh the access token server-side:
async function refreshAccessToken(req: express.Request): Promise<string> {
const response = await fetch(singleSignConfig.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: req.session.refreshToken,
client_id: singleSignConfig.clientId,
client_secret: singleSignConfig.clientSecret,
platform_guid: singleSignConfig.platformGuid,
}),
});
if (!response.ok) {
throw new Error("Token refresh failed — user must re-authenticate.");
}
const tokens = await response.json();
req.session.accessToken = tokens.access_token;
if (tokens.refresh_token) {
req.session.refreshToken = tokens.refresh_token;
}
return tokens.access_token;
}
Middleware for Automatic Refresh
async function ensureAuthenticated(
req: express.Request,
res: express.Response,
next: express.NextFunction
): Promise<void> {
if (!req.session.user) {
return res.redirect("/auth/login");
}
// Optionally check token expiry and refresh
try {
// If the access token is expired, refreshAccessToken will get a new one
await refreshAccessToken(req);
} catch {
return res.redirect("/auth/login");
}
next();
}
app.get("/dashboard", ensureAuthenticated, (req, res) => {
// user is authenticated and token is fresh
});
Security Best Practices for Server Apps
- Keep
client_secretout of source code. Use environment variables, a vault, or a secrets manager. - Use HTTPS everywhere. Redirect URIs in production must be HTTPS.
- Set secure session cookies.
httpOnly: true,secure: true,sameSite: 'lax'. - Store tokens in the server session, not in cookies sent to the browser.
- Use PKCE even though you have a secret. It provides defense in depth against code interception.
- Rotate your client secret periodically. See Register an App for instructions.
Token Introspection
Server apps can validate tokens by calling the introspection endpoint with client credentials:
async function introspectToken(token: string): Promise<Record<string, unknown>> {
const response = await fetch("https://api.singlesign.com/oauth/introspect", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization:
"Basic " +
Buffer.from(`${singleSignConfig.clientId}:${singleSignConfig.clientSecret}`).toString(
"base64"
),
},
body: new URLSearchParams({
token,
platform_guid: singleSignConfig.platformGuid,
}),
});
return response.json();
}
Response when active:
{
"active": true,
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"human_guid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"client_id": "abc123-def456-ghi789",
"scope": "openid profile email",
"exp": 1740000000,
"iat": 1739996400
}
Next Steps
- SPA with PKCE — If you also have a frontend SPA.
- Scopes & Consent — Fine-tune what data you request.
- Endpoint Reference — Full API docs.
- Tokens & Claims — Understand JWT structure and claims.
Last updated: 2026-02-16
Related docs
- SingleSign Developer DocumentationLearn how to integrate SingleSign identity and authentication into your applications using OAuth 2.0 and OpenID Connect.
- Firebase Auth comparisonSingleSign vs Firebase Authentication on lock-in, portability and consent, plus the cases where staying on Firebase is right.
- Auth0 comparisonSingleSign vs Auth0 compared on the difference that matters: who owns the account. A side-by-side table, then the three cases where each one is the right call.
- SaaS single sign-onSSO for SaaS applications using OAuth 2.0 and OpenID Connect: the flow to pick, the scopes to request, and a working integration path with SingleSign.
- Authentication for startupsAuthentication for startups without a monthly-active-user meter: standards-based OAuth 2.0 sign-in you will not have to replace when the product finds traction.
- React authentication with PKCEReact authentication done the recommended way: Authorization Code with PKCE, and no client secret in your JavaScript bundle.
- mobile app authenticationMobile app authentication for iOS and Android: Authorization Code with PKCE in a system browser, custom scheme redirects, and secure token storage on device.
- free identity providerSingleSign pricing in one line: sign-in is free, with no monthly-active-user meter. See exactly what is included, and what SingleSign Mail costs for businesses.