Express.js
Source:
en/integrations/express.md· Live: https://docs.1pass.dev/en/integrations/express LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).
📍 Jump-to Index
- L27-L77: ## SDK quick start (recommended)
- L78-L144: ## Manual integration (without the SDK)
- L145-L217: ## Health check endpoint
- L149-L155: ### 1. Env
- L156-L199: ### 2. Handler
- L200-L213: ### 3. Mount it on your app
- L214-L217: ### 4. Enable it in the console
Express.js
⚠️ Warning: 📋 Confidential client registration required The code in this guide sends
client_secretto/oauth/token(kept securely on the server). When you register the RP in the Developer Console, choose Client type: Confidential. For a Public/SPA client, follow the Public Clients guide and never putclient_secretin client-side code.
SDK quick start (recommended)
The official Node SDK @logi-auth/server builds the authorize URL and does the code exchange + id_token verification (RS256) in one call. For a confidential integration, the server verifying the audience (aud) is the SoT — the SDK path below is recommended; the manual integration that follows is a fallback.
npm install @logi-auth/server@^1.0.1 # recommend v1.0.1 security hardening (at_hash binding)import express from "express";
import { randomBytes } from "node:crypto";
import cookieParser from "cookie-parser";
import { LogiAuthServer } from "@logi-auth/server";
const app = express();
app.use(cookieParser(process.env.COOKIE_SECRET));
const logi = new LogiAuthServer({
clientId: process.env.LOGI_CLIENT_ID,
clientSecret: process.env.LOGI_CLIENT_SECRET, // confidential — server env only
redirectUri: process.env.LOGI_REDIRECT_URI,
// issuer defaults to https://api.1pass.dev · tokenIssuer defaults to "https://api.1pass.dev" · scopes default to [openid, profile:basic, email]
});
app.get("/auth/login", (req, res) => {
const state = randomBytes(16).toString("hex");
const nonce = randomBytes(16).toString("hex");
// store state·nonce in a signed cookie/session (checked in the callback)
res.cookie("logi_state", state, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600_000 });
res.cookie("logi_nonce", nonce, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600_000 });
res.redirect(logi.authorizationUrl({ state, nonce }));
});
app.get("/auth/callback", async (req, res) => {
if (!req.query.code || req.query.state !== req.cookies.logi_state) {
return res.status(400).send("state mismatch");
}
// exchangeCodeAndVerify: token exchange + id_token verification (alg==RS256 → kid → JWKS → signature → iss("https://api.1pass.dev") → aud → azp(conditional on multi-aud) → exp → iat → nonce → sub → at_hash) in one call
const session = await logi.exchangeCodeAndVerify({
code: String(req.query.code),
nonce: req.cookies.logi_nonce, // matched against id_token.nonce (required)
});
// session.sub — verified subject. Key your user records on this value
// session.email / session.accessToken / session.refreshToken / session.claims
res.clearCookie("logi_state"); res.clearCookie("logi_nonce");
res.redirect("/");
});LogiAuthServer/exchangeCodeAndVerify verifies the id_token in this order: alg (RS256) → kid → JWKS lookup → RS256 signature → iss (=="https://api.1pass.dev") → aud (must contain your client_id) → azp (only when aud is multi-valued; value=client_id) → exp (60s skew) → iat → nonce (if used) → sub → at_hash (last) (scope is the authorization axis and is not part of this sequence). azp is required only when aud is multi-valued (value=client_id) — the SDK checks it conditionally. at_hash binding (v1.0.1): the contract is at_hash = base64url_nopad(SHA256(the access_token's UTF-8 bytes)[first 16 bytes]) (OIDC §3.1.3.6). It is present-only — verified only when the id_token carries an at_hash claim AND an access_token is supplied, and a mismatch is rejected with at_hash_mismatch. Because the server issues at_hash, exchangeCodeAndVerify internally wires the access_token it received from the token exchange into verification, so a swapped access_token is caught before the session is returned. The PKCE (public) variant omits clientSecret and uses authorizationUrl({ state, nonce, codeChallenge }) + exchangeCodeAndVerify({ code, nonce, codeVerifier }). Standalone verification is verifyIdToken(idToken, { jwks, expected }) — to also confirm the access_token binding, use verifyIdToken(idToken, { jwks, expected, accessToken }). v1.0.1 selects the signing key from the JWKS with a kty=="RSA" + use/alg filter, so it is robust against EC-key contamination (JWKS reference). Errors are LogiAuthServerError (.code) and IdTokenError (.code).
Manual integration (without the SDK)
A fallback that implements PKCE + token exchange by hand. Verify the access token via JWKS.
import express from "express";
import crypto from "node:crypto";
import cookieParser from "cookie-parser";
const app = express();
app.use(cookieParser(process.env.COOKIE_SECRET));
const LOGI = process.env.LOGI_API_URL;
const CLIENT_ID = process.env.LOGI_CLIENT_ID;
const CLIENT_SECRET = process.env.LOGI_CLIENT_SECRET;
const REDIRECT = process.env.LOGI_REDIRECT_URI;
function b64url(buf) {
return Buffer.from(buf).toString("base64url");
}
app.get("/auth/login", (req, res) => {
const verifier = b64url(crypto.randomBytes(32));
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
const state = b64url(crypto.randomBytes(16));
res.cookie("logi_pkce", verifier, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600_000 });
res.cookie("logi_state", state, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600_000 });
const url = new URL(`${LOGI}/oauth/authorize`);
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("redirect_uri", REDIRECT);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", "openid profile:basic email");
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
res.redirect(url.toString());
});
app.get("/auth/callback", async (req, res) => {
// Validate missing code/state — guards against malicious callback injection
if (!req.query.code || !req.query.state) {
return res.status(400).send("missing code or state");
}
if (req.query.state !== req.cookies.logi_state) return res.status(400).send("state mismatch");
const tokens = await fetch(`${LOGI}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: req.query.code,
redirect_uri: REDIRECT,
code_verifier: req.cookies.logi_pkce,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
}).then(r => r.json());
res.cookie("logi_rt", tokens.refresh_token, { httpOnly: true, secure: true, sameSite: "strict" });
res.clearCookie("logi_pkce");
res.clearCookie("logi_state");
res.redirect("/");
});Health check endpoint
This is the Express handler for the RP active health check protocol.
1. Env
LOGI_CLIENT_ID=logi_xxxxxxxxxxxxxxxx
LOGI_RP_HEALTH_SECRET=<copy from the console>2. Handler
import { createHmac, timingSafeEqual } from "node:crypto";
const MAX_SKEW = 300; // seconds
app.get("/.well-known/logi-rp-health", (req, res) => {
// HTTP headers are case-insensitive — the code reads lower-case keys, but any case works when sending.
const ts = Number(req.get("x-logi-timestamp"));
const cid = String(req.get("x-logi-client-id") || "");
const sig = String(req.get("x-logi-signature") || "");
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > MAX_SKEW) {
return res.sendStatus(401);
}
if (cid !== process.env.LOGI_CLIENT_ID) {
return res.sendStatus(401);
}
// HMAC-SHA256 hex is always 64 hex chars. Reject malformed input BEFORE
// Buffer.from(..., "hex") — invalid hex decodes to a shorter buffer and
// timingSafeEqual would throw on length mismatch (⇒ 500).
if (!/^[0-9a-f]{64}$/i.test(sig)) {
return res.sendStatus(401);
}
const expected = createHmac("sha256", process.env.LOGI_RP_HEALTH_SECRET || "")
.update(`${ts}.${cid}`)
.digest("hex");
if (!timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) {
return res.sendStatus(401);
}
res.json({
status: "ok",
client_id: cid,
timestamp: new Date().toISOString(),
sdk_version: "logi-rp-integrate/1.0",
});
});3. Mount it on your app
If you split the handler above into its own router file, mount it with app.use; if you defined it directly on the same app, it works as-is.
// app.js (or server.js)
import logiHealthRouter from "./routes/logi-rp-health.js";
app.use(logiHealthRouter);
// Or, if you split out only the handler, mount it directly:
// app.get("/.well-known/logi-rp-health", logiHealthHandler);Use the Express router pattern to mount it as shown above. If you split out only the handler, use the second form.
4. Enable it in the console
Developer Console → your app → RP active health check ON → Save → copy the secret into your env → redeploy → 🟢 appears on the card within an hour.