Server-rendered apps
Sign users in from Next.js, Express, Hono or any back end with a confidential client and a server session.
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(andhttp://localhost:3000/callbackfor 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.
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
// 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):
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) orclient_secret_post. - PKCE is recommended for confidential clients too (OAuth 2.1); 9in accepts S256.
- Keep the ID token to pass
id_token_hintat sign-out (or passclient_id): 9in needs to know which application is signing out to accept itspost_logout_redirect_uri.