Go to developer

Add KEYRA Partner 2FA to an existing login

Default path: Node.js / TypeScript. Switch tabs for Java or REST. All secret calls stay on your server.

On this page

Prerequisites

  • KEYRA developer account and project
  • projectId, secret clientId (cp_*), and clientSecret (sk_*) — see API Keys & Credentials
  • A stable application user id to use as externalUserId
  • Node.js 18+ for the TypeScript examples (or Java 11+ / plain HTTP)

Installation

SERVERBash
npm install git+https://github.com/Ciright-Inc/keyra-typescript-sdk.git qrcode

qrcode is not part of KEYRA — it encodes the URL string returned as qrCode / enrollmentUrl / challengeUrl.

Environment variables

Env names are developer-defined examples. The SDKs accept config objects/constructors — they do not require these exact names.

.env (example)Bash
KEYRA_BASE_URL=https://auth.keyra.ie
KEYRA_PROJECT_ID=
KEYRA_CLIENT_ID=
KEYRA_CLIENT_SECRET=

Initialize KEYRA

SERVER · keyra.tsTypeScript
import { createKeyraPartner2FA } from "@keyra/typescript-sdk";

// Env var names below are examples — the SDK takes config values directly.
export const keyra = createKeyraPartner2FA({
  baseUrl: process.env.KEYRA_BASE_URL ?? "https://auth.keyra.ie",
  projectId: process.env.KEYRA_PROJECT_ID!,      // UUID from Developer Portal
  clientId: process.env.KEYRA_CLIENT_ID!,        // cp_test_… or cp_prod_…
  clientSecret: process.env.KEYRA_CLIENT_SECRET!, // sk_test_… or sk_prod_…
});

What happens here? Your backend constructs a Partner 2FA client that signs every request with Bearer clientId:clientSecret and scopes operations to projectId.

Check enrollment

SERVERTypeScript
const status = await keyra.get2FAStatus(user.id);

if (!status.enrolled) {
  // Run enrollment flow
} else {
  // Run authentication challenge flow
}
responseget2FAStatus (enrolled)
JSON
{
  "enrolled": true,
  "enabled": true,
  "identityId": "kid_…",
  "status": "active",
  "factors": [{ "type": "phone_otp", "verifiedAt": "2026-08-01T12:00:00.000Z" }],
  "enrolledAt": "2026-08-01T12:00:00.000Z"
}

Not enrolled → enrollment flow. Enrolled → authentication challenge flow.

Enroll the user

SERVERTypeScript
import QRCode from "qrcode";

const enrollment = await keyra.enable2FA(user.id);
// enrollment.qrCode === enrollment.enrollmentUrl (URL string — encode to QR yourself)
const qrDataUrl = await QRCode.toDataURL(enrollment.qrCode);

// Send qrDataUrl to your login UI, then wait:
const done = await keyra.waitForEnrollment(enrollment.enrollmentId);
// done.terminal === "COMPLETED" when successful
responseenable2FA / POST /v1/identities/enroll
JSON
{
  "identityId": "kid_…",
  "enrollmentId": "…",
  "status": "pending",
  "expiresIn": 600,
  "enrollmentUrl": "https://get-started.keyra.ie/?enroll=…",
  "pollUrl": "/v1/identities/enroll/…/status"
}

Start authentication & wait

SERVERTypeScript
const challenge = await keyra.startAuthentication(user.id);
// challenge.qrCode === challenge.challengeUrl
const qrDataUrl = await QRCode.toDataURL(challenge.qrCode);

const approved = await keyra.waitForChallengeApproval(challenge.challengeId);
if (!approved.verificationToken) {
  throw new Error("Missing verificationToken — do not poll again before consume");
}

const result = await keyra.consumeChallenge(
  challenge.challengeId,
  approved.verificationToken,
);

if (result.consumed) {
  // Create YOUR application session here (cookie/JWT/etc).
  // KEYRA does not create your app session.
}
FieldMeaning
challengeIdId for poll + consume — reuse this id; do not create a second challenge.
challengeUrl / qrCodeURL to show as QR / open on device.
expiresInSeconds until expiry (typically 120).
pollAfterMsSuggested poll delay from API (typically 1500).
verificationTokenPresent on approved poll (once) — required for consume.

Defaults: waitForChallengeApproval timeout 120s, interval 1.5s (uses pollAfterMs when returned). Terminal failures throw KeyraChallengeDeniedError / KeyraChallengeExpiredError / KeyraTimeoutError.

Consume verification & create your session

responseconsumeChallenge
JSON
{
  "consumed": true,
  "challengeId": "…",
  "identityId": "kid_…"
}

Complete Node.js example

Illustrative Express routes. Adapt session storage to your stack. Critical: /auth/challenge/wait reuses the challengeId from startAuthentication — it must not call startAuthentication again.

SERVER · express-example.tsTypeScript
import express from "express";
import QRCode from "qrcode";
import { createKeyraPartner2FA } from "@keyra/typescript-sdk";

const keyra = createKeyraPartner2FA({
  baseUrl: process.env.KEYRA_BASE_URL ?? "https://auth.keyra.ie",
  projectId: process.env.KEYRA_PROJECT_ID!,
  clientId: process.env.KEYRA_CLIENT_ID!,
  clientSecret: process.env.KEYRA_CLIENT_SECRET!,
});

const app = express();
app.use(express.json());

/** After your primary password check succeeds */
app.post("/auth/login/complete-2fa", async (req, res) => {
  const externalUserId = String(req.body.userId ?? "");
  if (!externalUserId) return res.status(400).json({ error: "userId required" });

  try {
    const status = await keyra.get2FAStatus(externalUserId);

    if (!status.enrolled) {
      const enrollment = await keyra.enable2FA(externalUserId);
      const qrDataUrl = await QRCode.toDataURL(enrollment.qrCode);
      // Client should display QR, then call /auth/enrollment/wait
      return res.json({
        action: "enroll",
        enrollmentId: enrollment.enrollmentId,
        qrDataUrl,
        expiresIn: enrollment.expiresIn,
      });
    }

    const challenge = await keyra.startAuthentication(externalUserId);
    const qrDataUrl = await QRCode.toDataURL(challenge.qrCode);
    // Client displays QR; then call /auth/challenge/wait with THIS challengeId
    return res.json({
      action: "challenge",
      challengeId: challenge.challengeId,
      qrDataUrl,
      expiresIn: challenge.expiresIn,
    });
  } catch (err) {
    console.error(err);
    return res.status(500).json({ error: "keyra_error" });
  }
});

app.post("/auth/enrollment/wait", async (req, res) => {
  const enrollmentId = String(req.body.enrollmentId ?? "");
  try {
    const done = await keyra.waitForEnrollment(enrollmentId);
    return res.json({ terminal: done.terminal, identityId: done.identityId });
  } catch (err) {
    console.error(err);
    return res.status(408).json({ error: "enrollment_timeout_or_failed" });
  }
});

app.post("/auth/challenge/wait", async (req, res) => {
  const challengeId = String(req.body.challengeId ?? "");
  try {
    // Reuse the same challengeId from startAuthentication — do NOT start a new challenge here.
    const approved = await keyra.waitForChallengeApproval(challengeId);
    if (!approved.verificationToken) {
      return res.status(409).json({ error: "missing_verification_token" });
    }
    const result = await keyra.consumeChallenge(challengeId, approved.verificationToken);
    if (!result.consumed) {
      return res.status(401).json({ error: "not_consumed" });
    }

    // Example only — replace with your real session strategy.
    req.session = req.session ?? {};
    (req.session as { userId?: string }).userId = String(req.body.userId);
    return res.json({ ok: true, identityId: result.identityId });
  } catch (err) {
    console.error(err);
    return res.status(401).json({ error: "challenge_failed" });
  }
});