Express
Add Sign in with PulseID to an Express app with plain OIDC and PKCE.
This guide adds Sign in with PulseID to an Express app with openid-client 6 and express-session. It is one file, the code flow with PKCE written out, and nothing else of PulseID's to install. It needs Node 22 or newer, and runs on Express 4 or 5. The quickstart shows the same flow with curl, and the reference lists every endpoint, scope and claim. The Next.js guide is the same flow with a sealed cookie in place of a server-side session.
1. Register the app
- In the portal, register a web app. Its redirect URI is your site plus
/auth/callback, for examplehttps://app.example.com/auth/callback. Add your site's home page as a post-logout redirect URI. - Copy the client ID and the client secret. The secret is shown once. If you lose it, rotate it (see Secrets).
Redirect URIs must be https, and PulseID refuses http://localhost. To develop locally, put the app behind an https address: a tunnel, or a local certificate on a name you own. Register that address.
Refresh tokens. A refresh token goes to a verified web app only. Unverified apps get no refresh token. Public apps get no refresh token either, even when their organization is verified. PulseID removes offline_access from the request without an error, so the sign-in still works and the answer holds no refresh_token. The code below handles both. Leave PULSEID_OFFLINE_ACCESS at false until your organization is verified (see Verification).
2. Install and configure
npm install express express-session openid-clientSet "type": "module" in package.json, and these variables. The issuer is https://id.warpulse.com; the code finds every endpoint from its discovery document at https://id.warpulse.com/.well-known/openid-configuration.
PULSEID_ISSUER=https://id.warpulse.com
PULSEID_CLIENT_ID=YOUR_CLIENT_ID
PULSEID_CLIENT_SECRET=YOUR_CLIENT_SECRET
PULSEID_REDIRECT_URI=https://bytesizedbites.example/auth/callback
# true only for a verified web app: it asks for offline_access and prompt=consent
PULSEID_OFFLINE_ACCESS=false
SESSION_SECRET=A_RANDOM_VALUE_FROM_THE_COMMAND_ABOVE| Name | What it is |
|---|---|
PULSEID_ISSUER | The issuer. It is the address of PulseID. |
PULSEID_CLIENT_ID | The client ID from the portal. |
PULSEID_CLIENT_SECRET | The client secret from the portal. Keep it on the server. |
PULSEID_REDIRECT_URI | The redirect URI you registered, exactly. The callback path is /auth/callback. |
PULSEID_OFFLINE_ACCESS | true only for a verified web app. It asks for offline_access with prompt=consent, to receive a refresh token. |
SESSION_SECRET | A random value that signs the session cookie. Make one with the command below. |
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='Express does not read .env by itself. Start the server with node --env-file=.env server.js, or set the variables in your host.
3. The server
The whole app is one file. Read the parts in this order.
- Discovery.
client.discoveryreads the discovery document at startup, and the client secret goes to the token endpoint with HTTP Basic (client_secret_basic), which is how a web app is registered. - The session.
express-sessionkeeps the tokens on the server and gives the browser an opaque id in the cookie__Host-sid:HttpOnly,Secure,SameSite=Lax. The default store keeps sessions in memory and forgets them when the process restarts, and it is for development only. In production use a store such as Redis or a database. Behind a proxy that ends TLS,trust proxylets Express see that the request was https, so it sends the secure cookie. - Sign-in.
/auth/loginmakes a random code verifier, a state and a nonce, keeps them in the session, and sends the person to PulseID with the PKCE challenge:code_challenge_method=S256, the only method PulseID has. WithPULSEID_OFFLINE_ACCESSon it also sendsoffline_accessandprompt=consent. Sendprompt=consentwhenever you ask foroffline_access: it is what yields the refresh token. - The return. PulseID sends the person to
/auth/callbackwithcode,stateandiss. One call toauthorizationCodeGrantchecks the state, sends the code with the PKCE verifier and the client secret (the code works once and for 60 seconds), and validates the ID token: its signature against the keys at thejwks_uri, the issuer, the audience (your client ID), the expiry and the nonce. It throws if any of them is wrong. The handler then gives the person a new session id, so a session id from before the sign-in is of no use.
import express from 'express'
import session from 'express-session'
import * as client from 'openid-client'
const { PULSEID_ISSUER, PULSEID_CLIENT_ID, PULSEID_CLIENT_SECRET, PULSEID_REDIRECT_URI, SESSION_SECRET } = process.env
// Refresh tokens come with offline_access, and only a verified web app gets one.
const OFFLINE = process.env.PULSEID_OFFLINE_ACCESS === 'true'
const SCOPE = OFFLINE ? 'openid profile email offline_access' : 'openid profile email'
const REDIRECT = new URL(PULSEID_REDIRECT_URI)
const REFRESH_MARGIN_SECONDS = 60
const REFRESH_SHARE_MS = 30 * 1000
// Discovery: the endpoints and the signing keys. The client secret goes to the token endpoint with HTTP Basic
// (client_secret_basic), which is how a web app is registered.
const config = await client.discovery(new URL(PULSEID_ISSUER), PULSEID_CLIENT_ID, undefined, client.ClientSecretBasic(PULSEID_CLIENT_SECRET))
const app = express()
app.set('trust proxy', 1) // behind the proxy that ends TLS, so the secure cookie is sent
// The browser holds only an opaque session id. The tokens stay on the server. The default store keeps sessions in
// memory and forgets them on a restart: use a real store (Redis, a database) in production.
app.use(
session({
name: '__Host-sid',
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 30 * 24 * 3600 * 1000 },
}),
)
const regenerate = (req) => new Promise((resolve, reject) => req.session.regenerate((error) => (error ? reject(error) : resolve())))
const save = (req) => new Promise((resolve, reject) => req.session.save((error) => (error ? reject(error) : resolve())))
const escapeHtml = (text) => String(text).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';')
// Only ever send the person back to a path on this site: one slash, then no second slash, backslash, space or control character
// (a browser drops a tab, so "/<tab>/evil.com" would mean "//evil.com"), and one that still resolves to this origin.
const SITE = 'https://site.example'
const safePath = (value) => {
if (typeof value !== 'string' || !/^\/(?![/\\])[^\u0000-\u0020\u007f\\]*$/.test(value)) return '/'
return new URL(value, SITE).origin === SITE ? value : '/'
}
const expiresAt = (tokens) => Math.floor(Date.now() / 1000) + (tokens.expiresIn() ?? 600)
app.get('/auth/login', async (req, res, next) => {
try {
const verifier = client.randomPKCECodeVerifier()
const state = client.randomState()
const nonce = client.randomNonce()
const url = client.buildAuthorizationUrl(config, {
redirect_uri: REDIRECT.href,
scope: SCOPE,
// offline_access is only honoured together with prompt=consent.
...(OFFLINE ? { prompt: 'consent' } : {}),
code_challenge: await client.calculatePKCECodeChallenge(verifier),
code_challenge_method: 'S256',
state,
nonce,
})
req.session.login = { verifier, state, nonce, returnTo: safePath(req.query.returnTo) }
await save(req) // stored before the browser leaves
res.redirect(303, url.href)
} catch (error) {
next(error)
}
})
app.get('/auth/callback', async (req, res) => {
const login = req.session.login
if (!login) return res.status(400).send('The sign-in expired. Start again.')
try {
// The registered redirect URI plus the query PulseID sent back (code, state, iss), not the Host header.
const currentUrl = new URL(REDIRECT.href)
currentUrl.search = new URL(req.originalUrl, REDIRECT).search
// One call checks state, exchanges the code with the PKCE verifier and the client secret, and validates the ID token:
// signature through the JWKS, issuer, audience, expiry and nonce. It throws on anything wrong, an error response included.
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: login.verifier,
expectedState: login.state,
expectedNonce: login.nonce,
idTokenExpected: true,
})
const claims = tokens.claims()
await regenerate(req) // a new session id at sign-in
req.session.user = {
sub: claims.sub,
email: typeof claims.email === 'string' ? claims.email : null,
name: typeof claims.name === 'string' ? claims.name : null,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token ?? null,
accessExpires: expiresAt(tokens),
}
await save(req)
res.redirect(303, login.returnTo)
} catch (error) {
console.error('PulseID sign-in failed:', error.code, error.message) // never the whole error: it can hold the response body
delete req.session.login // the login secrets are good for one return
res.status(400).send('Sign-in failed. Start again.')
}
})
// A refresh token works once: the answer holds a new one. Use a token a second time and PulseID ends the whole sign-in.
// Requests that arrive together carry the same token, so they share one refresh. (This is one server process. With
// several, refresh behind a lock they all share.)
const refreshes = new Map()
function refreshOnce(refreshToken) {
let pending = refreshes.get(refreshToken)
if (!pending) {
pending = client.refreshTokenGrant(config, refreshToken)
refreshes.set(refreshToken, pending)
pending.catch(() => refreshes.delete(refreshToken))
setTimeout(() => refreshes.delete(refreshToken), REFRESH_SHARE_MS).unref()
}
return pending
}
// Middleware for the routes that need a signed-in person.
async function requireUser(req, res, next) {
const user = req.session.user
const login = '/auth/login?returnTo=' + encodeURIComponent(req.originalUrl)
if (!user) return res.redirect(303, login)
if (user.refreshToken && user.accessExpires - Date.now() / 1000 <= REFRESH_MARGIN_SECONDS) {
try {
const tokens = await refreshOnce(user.refreshToken)
user.accessToken = tokens.access_token
user.refreshToken = tokens.refresh_token ?? user.refreshToken
user.accessExpires = expiresAt(tokens)
} catch {
// invalid_grant: the person signed out of PulseID, revoked the app, or the 30 days ended. Start again.
return req.session.destroy(() => res.redirect(303, login))
}
}
req.user = user
next()
}
app.get('/account', requireUser, (req, res) => {
const who = req.user.name ?? req.user.email ?? req.user.sub
res.send('<p>Signed in as ' + escapeHtml(who) + '.</p><form action="/auth/logout" method="post"><button type="submit">Sign out</button></form>')
})
// POST only, from a form on your own site, so another site cannot sign people out.
app.post('/auth/logout', async (req, res, next) => {
if (req.get('origin') !== REDIRECT.origin) return res.status(403).send('Forbidden')
try {
const refreshToken = req.session.user?.refreshToken
// Ends the grant at PulseID. If it fails, the token still expires on its own.
if (refreshToken) await client.tokenRevocation(config, refreshToken, { token_type_hint: 'refresh_token' }).catch(() => undefined)
// PulseID asks the person to confirm, ends their PulseID session in this browser, and sends them back to a
// post-logout redirect URI you registered for the app.
const url = client.buildEndSessionUrl(config, { client_id: PULSEID_CLIENT_ID, post_logout_redirect_uri: new URL('/', REDIRECT).href })
req.session.destroy(() => res.clearCookie('__Host-sid', { secure: true, httpOnly: true, sameSite: 'lax' }).redirect(303, url.href))
} catch (error) {
next(error)
}
})
app.listen(3000)4. The identity
The ID token is a signed JWT with a 10 minute life, and it is the identity: sub is the person, stable for that account, and email and name come with the email and profile scopes. Use sub as your key for the person, not the email address. The access token is opaque. It is good for the userinfo endpoint for 10 minutes and tells you nothing by itself. If all you need is who the person is, keep sub and the profile in the session and drop the tokens.
5. Refresh
With a refresh token, requireUser trades it for new tokens when 60 seconds or fewer are left on the access token, and stores them in the session. The refresh token lasts at most 30 days from the sign-in it came from, so the session cookie lasts that long too. When a refresh fails with invalid_grant, the person signed out of PulseID, revoked the app, or the 30 days ended. The middleware ends the session and starts a new sign-in.
A refresh token works once. Every refresh returns a new refresh token and uses up the old one. Use a refresh token a second time and PulseID ends the whole sign-in, and the person has to sign in again. A page that loads with several requests at once sends the same token several times, so refreshOnce makes them share one refresh and keeps its result for 30 seconds. That holds in one server process. If you run several instances, take a lock that they all share, such as one in your session store or your database.
6. Sign out
/auth/logout accepts a POST from your own site only, so another site cannot sign people out. It revokes the refresh token, destroys the session and sends the browser to PulseID's end-session endpoint, which asks the person to confirm and then ends their PulseID session in that browser. The person comes back to the post-logout redirect URI you registered. An address that is not registered is refused, so register your home page first.
Try it
- Start the server behind your https address and open
/account. You go to PulseID, sign in, see the consent screen for your organization, and come back signed in. - Open the browser's cookies for your site. You see only
__Host-sid, and no token. - Sign out. Opening
/accountagain starts a new sign-in.
If the callback answers "Sign-in failed", read your server log: invalid_client means the client ID or secret is wrong, revoked or the app is paused, and invalid_grant means the code was used or expired, or the redirect URI or verifier did not match what was sent. The reference lists the errors, and Secrets covers rotating the secret without an outage.