ex

JavaScript / Node.js

Sign in with NIA — Express

Add “Sign in with NIA” to an Express app with openid-client and the standard OpenID Connect flow. Follow the steps top to bottom — tick each one off as you go.

~15 minMediumopenid-client
Try OAuth Flow
Integration progress
0/16

01Overview

This guide adds “Sign in with NIA” to an Express application using the standard OpenID Connect flow. NIA is a conformant OIDC provider, so you use openid-client — the standard OIDC/OAuth client for Node.js — with no NIA-specific SDK.

Express runs as a confidential client: the browser is redirected to NIA to sign in, and your Express server exchanges the returned code for tokens using your client secret. openid-client handles PKCE, state, and ID-token signature validation for you.

  • Flow: Authorization Code + PKCE (S256)
  • Tokens: RS256 access & ID tokens, opaque refresh token
  • You write: three routes — login, callback, logout

02Requirements

  • Node.js 18+
  • A NIA account with access to the client dashboard
  • A registered OAuth client (client_id + client_secret)
  • Your app's redirect URI registered on that client
  • express-session (or another session store) configured on your app

03Installation

Install Express, a session store, and openid-client. openid-client fetches the discovery document and JWKS over HTTP.

npm install express express-session openid-client

04Required OAuth library

Discover NIA once at startup and build a client instance. Issuer.discover reads NIA's discovery document — every endpoint, the JWKS URL, and supported algorithms are configured automatically from it.

auth/client.js
const { Issuer, generators } = require("openid-client");

let client;

async function getClient() {
  if (client) return client;

  const nia = await Issuer.discover(process.env.NIA_ISSUER);

  client = new nia.Client({
    client_id: process.env.NIA_CLIENT_ID,
    client_secret: process.env.NIA_CLIENT_SECRET,
    redirect_uris: ["http://localhost:3000/auth/callback"],
    response_types: ["code"],
  });

  return client;
}

module.exports = { getClient, generators };
Add offline_access to the scope only if you need refresh tokens (see step 14).

05Environment Variables

Never hard-code secrets. Keep them in a .env file (git-ignored) and load them with dotenv before the app starts.

NIA_ISSUER=https://dev-api.niaauth.com
NIA_CLIENT_ID=your-client-id
NIA_CLIENT_SECRET=your-client-secret
SESSION_SECRET=replace-with-a-long-random-value

06Register OAuth Client

In the NIA dashboard, create the client your Express app will authenticate as:

  • Open Dashboard → Clients → New client
  • Client type: Web (confidential)
  • Allowed scopes: openid email profile (add offline_access for refresh)
  • Add your redirect URI (next step)
  • Copy the client_id and client_secret — the secret is shown once

07Redirect URI

NIA matches redirect URIs exactly — scheme, host, port, and path must be identical, with no trailing-slash difference. Register one URI per environment.

redirect URIs
# Development
http://localhost:3000/auth/callback

# Production
https://app.example.com/auth/callback
A mismatch is the #1 integration error. http://localhost:3000/auth/callback/ (trailing slash) will be rejected.

08Issuer

The issuer is the single URL every OIDC library needs. Its discovery document advertises all endpoints below.

issuer & endpoints
Issuer:        https://dev-api.niaauth.com
Discovery:     https://dev-api.niaauth.com/.well-known/openid-configuration
Authorization: https://dev-api.niaauth.com/oauth/authorize
Token:         https://dev-api.niaauth.com/oauth/token
UserInfo:      https://dev-api.niaauth.com/oauth/userinfo
JWKS:          https://dev-api.niaauth.com/oauth/jwks.json

09Authorization Flow

Add a login route that redirects to NIA. Generate a PKCE verifier/challenge and a state value, and stash them in the session so the callback route can verify them — express-session must be active before this route.

auth/routes.js
const express = require("express");
const { getClient, generators } = require("./client");

const router = express.Router();

router.get("/auth/login", async (req, res) => {
  const client = await getClient();

  const code_verifier = generators.codeVerifier();
  const code_challenge = generators.codeChallenge(code_verifier);
  const state = generators.state();

  req.session.code_verifier = code_verifier;
  req.session.state = state;

  const url = client.authorizationUrl({
    scope: "openid email profile",
    code_challenge,
    code_challenge_method: "S256",
    state,
  });

  res.redirect(url);
});

module.exports = router;

10Token Exchange

On callback, exchange the authorization code for tokens. client.callback verifies state, sends the PKCE verifier, exchanges the code, and validates the ID token — all in one call.

auth/routes.js
router.get("/auth/callback", async (req, res, next) => {
  try {
    const client = await getClient();
    const params = client.callbackParams(req);

    const tokenSet = await client.callback(
      "http://localhost:3000/auth/callback",
      params,
      {
        code_verifier: req.session.code_verifier,
        state: req.session.state,
      }
    );

    req.session.user = tokenSet.claims();       // validated ID-token claims
    req.session.access_token = tokenSet.access_token;
    req.session.refresh_token = tokenSet.refresh_token;

    delete req.session.code_verifier;
    delete req.session.state;

    res.redirect("/");
  } catch (err) {
    next(err);
  }
});

11JWKS Validation

NIA signs ID tokens with RS256. openid-client fetches the public keys from the JWKS endpoint (discovered automatically), caches them, and verifies the signature, iss, aud, exp, and nonce inside client.callback. You do not verify manually.

To confirm what was validated, inspect the parsed claims:

verify (illustrative)
const claims = tokenSet.claims();

console.assert(claims.iss === process.env.NIA_ISSUER, "issuer mismatch");
console.assert(claims.aud === process.env.NIA_CLIENT_ID, "audience mismatch");
// signature (RS256, via JWKS), exp and nonce are already verified by openid-client.
Keys rotate. Because openid-client refetches JWKS by kid, rotation needs no code change on your side.

12Session Management

Configure express-session once at app startup, then read req.session.user as normal. Protect routes with a small middleware:

const session = require("express-session");

app.set("trust proxy", 1);
app.use(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 * 8,
  },
}));

13Refresh Tokens

If you requested offline_access, NIA returns a refresh token. Exchange it for a fresh access token when the current one expires. NIA rotates refresh tokens, so always store the new one.

auth/refresh.js
const { getClient } = require("./client");

async function refreshSession(req) {
  if (!req.session.refresh_token) return null;

  const client = await getClient();
  const tokenSet = await client.refresh(req.session.refresh_token);

  req.session.access_token = tokenSet.access_token;
  // NIA rotates the refresh token — persist the replacement.
  req.session.refresh_token = tokenSet.refresh_token ?? req.session.refresh_token;

  return tokenSet;
}

module.exports = { refreshSession };

14Logout

Destroy the Express session and send the user to NIA's end-session endpoint so they are signed out of NIA too. Register your post_logout_redirect_uri on the client.

auth/routes.js
router.get("/auth/logout", async (req, res, next) => {
  const client = await getClient();
  const idToken = req.session.user?.id_token;

  req.session.destroy((err) => {
    if (err) return next(err);

    const url = client.endSessionUrl({
      id_token_hint: idToken,
      post_logout_redirect_uri: "http://localhost:3000/",
    });
    res.redirect(url);
  });
});

15Deployment

Before going live:

  • Register the production redirect URI (HTTPS) on the client
  • Set all env vars in your host — never commit .env
  • Run behind HTTPS and set app.set("trust proxy", 1) so secure cookies work behind a load balancer
  • Enable secure, httpOnly, sameSite: "lax" session cookies
  • Use a persistent session store (Redis/DB) instead of the in-memory default across multiple workers
server.js (production)
const RedisStore = require("connect-redis").default;
const { createClient } = require("redis");

const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect();

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    sameSite: "lax",
    secure: true,
    maxAge: 1000 * 60 * 60 * 8,
  },
}));

16Troubleshooting

  • redirect_uri_mismatch — the URI must match the registered one exactly (no trailing slash, correct scheme/port).
  • invalid_client — wrong client_secret, or the client's token-auth method differs from what openid-client sends.
  • invalid_grant / PKCE failed — the session was lost between login and callback. Ensure express-session is configured before your auth routes and sameSite: "lax" is set.
  • state mismatch — same root cause: the session cookie isn't surviving the redirect. Check cookie domain/secure settings and trust proxy.
  • invalid_scope — the scope isn't in the client's allowed_scopes; add it in the dashboard.
  • ID-token signature — usually server clock skew; sync time via NTP.