Build your app

Server-rendered apps

Sign users in from Next.js, Express, Hono or any back end with a confidential client and a server session.

View .md llms.txt

A server-rendered app (Next.js, Express, Hono, Rails, Django, Go…) can keep a secret, so it is a confidential client: it exchanges the code on the server with its client secret, keeps the tokens in the server-side session, and the browser only ever holds a session cookie.

#Set up the application

Applications → Create application → Regular Web App, then:

  • Allowed callback URLs: https://app.acme.com/callback (and http://localhost:3000/callback for development).
  • Allowed logout URLs: https://app.acme.com/.
  • Copy the Client ID, and the Client secret from the Credentials tab into your server's environment as NINE_CLIENT_SECRET. Rotate it there when needed; the previous secret keeps working until the next rotation.

#Node.js with openid-client

openid-client is a certified OpenID Connect client for Node, Bun, Deno and Workers.

ts
import * as client from "openid-client";
import express from "express";
import session from "express-session";

const config = await client.discovery(new URL(process.env.NINE_ISSUER!), process.env.NINE_CLIENT_ID!, process.env.NINE_CLIENT_SECRET!);
const redirect_uri = "https://app.acme.com/callback";

const app = express();
app.use(session({ secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: true, sameSite: "lax" } }));

app.get("/login", async (req, res) => {
  const verifier = client.randomPKCECodeVerifier();
  const state = client.randomState();
  req.session.login = { verifier, state, returnTo: req.query.returnTo };
  res.redirect(
    client.buildAuthorizationUrl(config, {
      redirect_uri,
      scope: "openid profile email offline_access",
      code_challenge: await client.calculatePKCECodeChallenge(verifier),
      code_challenge_method: "S256",
      state,
      // prompt: "create" opens the sign-up form; ui_locales: "de" picks the language
    }).href,
  );
});

app.get("/callback", async (req, res) => {
  const { verifier, state, returnTo } = req.session.login ?? {};
  const tokens = await client.authorizationCodeGrant(config, new URL(req.originalUrl, "https://app.acme.com"), { pkceCodeVerifier: verifier, expectedState: state });
  req.session.login = undefined;
  req.session.user = tokens.claims();          // sub, email, name…
  req.session.tokens = { id_token: tokens.id_token, refresh_token: tokens.refresh_token, access_token: tokens.access_token, expires_at: Date.now() + (tokens.expires_in ?? 0) * 1000 };
  res.redirect(typeof returnTo === "string" && returnTo.startsWith("/") ? returnTo : "/");
});

app.get("/logout", (req, res) => {
  const idToken = req.session.tokens?.id_token;
  req.session.destroy(() => {
    res.redirect(client.buildEndSessionUrl(config, { id_token_hint: idToken, post_logout_redirect_uri: "https://app.acme.com/" }).href);
  });
});

Refresh an expired access token with client.refreshTokenGrant(config, refreshToken); store the new refresh token it returns (they rotate).

#Next.js with Auth.js

ts
// auth.ts
import NextAuth from "next-auth";

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    {
      id: "9in",
      name: "9in",
      type: "oidc",
      issuer: process.env.NINE_ISSUER,           // https://acme.9in.io
      clientId: process.env.NINE_CLIENT_ID,
      clientSecret: process.env.NINE_CLIENT_SECRET,
      authorization: { params: { scope: "openid profile email" } },
    },
  ],
});

Register https://app.acme.com/api/auth/callback/9in as the callback URL. Protect pages with const session = await auth() in server components, or export { auth as middleware } from "./auth".

#The 9in Node SDK

@9in-io/node wraps the same flow with 9in's defaults (PKCE, state, issuer and ID token checks, refresh with rotation):

ts
import { ServerAuth } from "@9in-io/node";

const auth = new ServerAuth({
  issuer: process.env.NINE_ISSUER!,
  clientId: process.env.NINE_CLIENT_ID!,
  clientSecret: process.env.NINE_CLIENT_SECRET,
  redirectUri: "https://app.acme.com/callback",
});

app.get("/login", async (req, res) => {
  const { url, pending } = await auth.createLogin({ returnTo: req.query.returnTo as string });   // screen: "signup" for the sign-up form
  req.session.nineLogin = pending;
  res.redirect(url);
});

app.get("/callback", async (req, res) => {
  const tokens = await auth.handleCallback(req.originalUrl, req.session.nineLogin);
  req.session.nineLogin = undefined;
  req.session.tokens = tokens;
  req.session.user = auth.claims(tokens);
  res.redirect(tokens.returnTo ?? "/");
});

// later: req.session.tokens = await auth.ensureFresh(req.session.tokens)
app.get("/logout", async (req, res) => {
  const tokens = req.session.tokens;
  if (tokens?.refreshToken) await auth.revoke(tokens.refreshToken);
  req.session.destroy(() => undefined);
  res.redirect(await auth.logoutUrl({ idToken: tokens?.idToken, returnTo: "https://app.acme.com/" }));
});

#Other languages

Any certified OpenID Connect relying-party library works with the issuer, client ID and secret: Spring Security (spring.security.oauth2.client.provider.nine.issuer-uri), ASP.NET Core (AddOpenIdConnect with Authority), Django (mozilla-django-oidc), Rails (omniauth_openid_connect), Go (github.com/coreos/go-oidc). Point them at the issuer; everything else comes from <issuer>/.well-known/openid-configuration.

#Good to know

  • Client authentication: client_secret_basic (HTTP Basic) or client_secret_post.
  • PKCE is recommended for confidential clients too (OAuth 2.1); 9in accepts S256.
  • Keep the ID token to pass id_token_hint at sign-out (or pass client_id): 9in needs to know which application is signing out to accept its post_logout_redirect_uri.