Human Pass DocsSandbox
DashboardCreate account
Menu

Next.js

Sign-in with Human Pass in a Next.js App Router app, using two route handlers and openid-client. Your own session handling stays as it is.

Setup

shell
npm install openid-client
.env.local
HP_ISSUER=https://id.sandbox.humanpass.co
HP_CLIENT_ID=hp_test_…
HP_CLIENT_SECRET=hps_…
HP_REDIRECT_URI=http://localhost:3000/auth/callback

Register http://localhost:3000/auth/callback as the client's redirect URI in the dashboard (in production, your https address).

The Human Pass client

lib/humanpass.ts
import "server-only";
import * as oidc from "openid-client";

let config: Promise<oidc.Configuration> | undefined;

/** Human Pass's settings, discovered once. */
export function humanPass() {
  config ??= oidc.discovery(
    new URL(process.env.HP_ISSUER!),
    process.env.HP_CLIENT_ID!,
    { id_token_signed_response_alg: "ES256" },
    oidc.ClientSecretBasic(process.env.HP_CLIENT_SECRET!),
  );
  return config;
}

/** Where the sign-in's PKCE verifier, state and nonce wait for the callback. */
export const FLOW_COOKIE = "hp_flow";

The sign-in route

app/auth/login/route.ts
import * as oidc from "openid-client";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { FLOW_COOKIE, humanPass } from "@/lib/humanpass";

export async function GET() {
  const config = await humanPass();
  const flow = { verifier: oidc.randomPKCECodeVerifier(), state: oidc.randomState(), nonce: oidc.randomNonce() };
  (await cookies()).set(FLOW_COOKIE, JSON.stringify(flow), {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax", // sent on the redirect back from Human Pass
    maxAge: 15 * 60,
    path: "/auth",
  });
  const url = oidc.buildAuthorizationUrl(config, {
    redirect_uri: process.env.HP_REDIRECT_URI!,
    scope: "openid personhood",
    code_challenge: await oidc.calculatePKCECodeChallenge(flow.verifier),
    code_challenge_method: "S256",
    state: flow.state,
    nonce: flow.nonce,
  });
  redirect(url.href);
}

The callback route

app/auth/callback/route.ts
import * as oidc from "openid-client";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import type { NextRequest } from "next/server";
import { FLOW_COOKIE, humanPass } from "@/lib/humanpass";
import { accountFor, startSession } from "@/lib/accounts"; // your own code

export async function GET(request: NextRequest) {
  const jar = await cookies();
  const flow = JSON.parse(jar.get(FLOW_COOKIE)?.value ?? "null");
  jar.delete({ name: FLOW_COOKIE, path: "/auth" });
  // Cancelled, or couldn't be verified: Human Pass doesn't say which.
  if (!flow || request.nextUrl.searchParams.has("error")) redirect("/?signin=failed");

  // Rebuild the callback address from the registered one: behind a proxy, request.url can differ.
  const callbackUrl = new URL(request.nextUrl.search, process.env.HP_REDIRECT_URI!);
  const tokens = await oidc.authorizationCodeGrant(await humanPass(), callbackUrl, {
    pkceCodeVerifier: flow.verifier,
    expectedState: flow.state,
    expectedNonce: flow.nonce,
    idTokenExpected: true,
  });
  const claims = tokens.claims()!;
  if (claims.verified_human !== true) redirect("/?signin=failed");

  const account = await accountFor(claims.sub, claims.previous_sub as string | undefined);
  await startSession(account.id);
  redirect("/");
}

accountFor is the "one person, one account" lookup from the quickstart; startSession is however your app signs people in (a session cookie, or your auth library's session).

The button

app/page.tsx
export default function Home() {
  return (
    <a href="/auth/login" className="button">
      Continue with Human Pass
    </a>
  );
}

The Human Pass sandbox is live for demos and testing integrations; production is coming soon. Interfaces may still change; we'll give notice of anything that breaks an integration.

humanpass.co · Security overview · Privacy