Skip to content

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 secretNot usedSent with token request
PKCERequiredRecommended (defense in depth)
Token storageBrowser sessionStorageServer-side session / encrypted cookie
Token exchangeBrowser → SingleSignServer → SingleSign (server-to-server)

Prerequisites

  • A registered SingleSign app with Application Type set to "Server Web Application."
  • Your client_id, client_secret, and platform_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

  1. Keep client_secret out of source code. Use environment variables, a vault, or a secrets manager.
  2. Use HTTPS everywhere. Redirect URIs in production must be HTTPS.
  3. Set secure session cookies. httpOnly: true, secure: true, sameSite: 'lax'.
  4. Store tokens in the server session, not in cookies sent to the browser.
  5. Use PKCE even though you have a secret. It provides defense in depth against code interception.
  6. 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

Last updated: 2026-02-16