Skip to main content
Didit exposes an OpenID Connect authorization server purpose-built for delegated identity verification. It implements the contract Okta’s “bring your own identity verification vendor” (BYO IDV) framework expects: Pushed Authorization Requests (PAR, RFC 9126), the authorization code flow with PKCE, and an ID token carrying an OpenID Connect verified_claims object. The same endpoints work for any relying party that speaks this profile, including Auth0 post-login Actions.

Endpoints

Every endpoint lives under the identity verification bridge base URL.
EnvironmentBase URL
Productionhttps://apx.didit.me/auth/idv
Sandboxhttps://apx.staging.didit.me/auth/idv
The discovery document at {base}/.well-known/openid-configuration advertises all of them:
PurposeEndpoint
Pushed Authorization RequestPOST {base}/par
AuthorizationGET {base}/authorize
TokenPOST {base}/token
JWKSGET {base}/jwks
DiscoveryGET {base}/.well-known/openid-configuration
Client authentication uses client_secret_basic or client_secret_post. The authorization code flow requires PKCE with code_challenge_method=S256. ID tokens are signed with RS256; verify them against the JWKS endpoint.

Flow

  1. The relying party pushes the authorization request to POST /par, including the PKCE challenge, state, nonce, its registered redirect_uri, the scopes openid profile identity_assurance, and the claims it wants verified. Didit returns a single-use request_uri that is valid for 60 seconds.
  2. The relying party redirects the user’s browser to GET /authorize?request_uri=.... Didit creates a verification session and sends the user into the hosted verification flow.
  3. When the user finishes, Didit evaluates the verification decision, compares the extracted identity against the claims that were pushed, and returns the user to the relying party’s redirect_uri with a one-time code and the original state.
  4. The relying party exchanges the code at POST /token with its code_verifier and receives a signed ID token.

Requesting claims

Send the claims to compare inside the standard OpenID Connect claims parameter of the PAR request. Both given_name and family_name are supported, and both are always compared fuzzily. verified_claims is an array, matching Okta’s BYO IDV profile and the ID token response below.
{
  "id_token": {
    "verified_claims": [
      {
        "verification": {
          "trust_framework": { "value": "IDV-DELEGATED" },
          "assurance_level": { "value": "VERIFIED" }
        },
        "claims": {
          "given_name": { "value": "Lucas", "fuzzy": true },
          "family_name": { "value": "Fraggioli", "fuzzy": true }
        }
      }
    ]
  }
}

The ID token

The ID token carries a verified_claims array. assurance_level is VERIFIED only when the verification session was approved and every requested claim matched the verified document. Otherwise it is FAILED.
{
  "iss": "https://apx.didit.me/auth/idv",
  "sub": "eea9c5ca-63e6-437b-8e94-131c33f5dce8",
  "aud": "<your client id>",
  "nonce": "<your nonce>",
  "verified_claims": [
    {
      "verification": {
        "trust_framework": "IDV-DELEGATED",
        "assurance_level": "VERIFIED",
        "time": "2026-07-09T11:59:33Z",
        "verification_process": "eea9c5ca-63e6-437b-8e94-131c33f5dce8"
      },
      "claims": {
        "given_name": { "value": "Lucas Agustin", "fuzzy": true },
        "family_name": { "value": "Fraggioli", "fuzzy": true }
      }
    }
  ]
}
verification_process is the Didit session id, so you can look the verification up in the console or through the sessions API.
Treat the user as verified only when trust_framework is IDV-DELEGATED and assurance_level is VERIFIED. Any other combination means the verification did not succeed.

Configure Okta

In the Okta Admin Console, add a custom identity verification vendor and fill in the values below. Okta creates the integration as an identity provider of type IDV_STANDARD with protocol ID_PROOFING, and you then reference it from an Account Management Policy rule.
FieldValue
Issuerhttps://apx.didit.me/auth/idv
PAR request URLhttps://apx.didit.me/auth/idv/par
Authorize URLhttps://apx.didit.me/auth/idv/authorize
Token URLhttps://apx.didit.me/auth/idv/token
JWKS URLhttps://apx.didit.me/auth/idv/jwks
Scopesopenid, profile, identity_assurance
Client ID / Client secretProvided by Didit
Give Didit your callback URL so it can be registered on your client. Okta builds it as {yourOktaOrgUrl}/idp/identity-verification/callback.

Configure Auth0

Auth0 has no native IDV vendor slot, so verification runs from a post-login Action that redirects the user into the same flow. Store DIDIT_IDV_BASE, DIDIT_CLIENT_ID and DIDIT_CLIENT_SECRET as Action secrets, and register https://{yourAuth0Domain}/continue as the redirect URI with Didit.
Auth0 appends its own state to the /authorize redirect so it can resume the login. Didit adopts that state and echoes it back to /continue, which is what lets Auth0 complete the transaction - you don’t thread Auth0’s state through the PAR body (it isn’t generated until the redirect).
const crypto = require("crypto");

// Correlate each login's PKCE verifier with its own transaction so a retried
// or overlapping login can't overwrite it (which would fail the token exchange).
const txKey = (event) =>
  event.transaction?.id || event.request?.query?.state || "default";

const basicAuth = (event) =>
  Buffer.from(
    `${event.secrets.DIDIT_CLIENT_ID}:${event.secrets.DIDIT_CLIENT_SECRET}`
  ).toString("base64");

exports.onExecutePostLogin = async (event, api) => {
  if (event.user.app_metadata?.idv_status === "verified") return;

  const verifier = crypto.randomBytes(48).toString("base64url");
  const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");

  const pending = { ...event.user.app_metadata?.idv_pkce };
  pending[txKey(event)] = verifier;
  api.user.setAppMetadata("idv_pkce", pending);

  const params = {
    response_type: "code",
    client_id: event.secrets.DIDIT_CLIENT_ID,
    redirect_uri: `https://${event.request.hostname}/continue`,
    scope: "openid profile identity_assurance",
    code_challenge: challenge,
    code_challenge_method: "S256",
    login_hint: event.user.email ?? "",
  };

  // Ask Didit to match the scanned document against this user's known name, so an
  // approved session for a different person can't mark this account verified.
  if (event.user.given_name && event.user.family_name) {
    params.claims = JSON.stringify({
      id_token: {
        verified_claims: [
          {
            verification: { trust_framework: { value: "IDV-DELEGATED" } },
            claims: {
              given_name: { value: event.user.given_name, fuzzy: true },
              family_name: { value: event.user.family_name, fuzzy: true },
            },
          },
        ],
      },
    });
  }

  const res = await fetch(`${event.secrets.DIDIT_IDV_BASE}/par`, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: `Basic ${basicAuth(event)}`,
    },
    body: new URLSearchParams(params).toString(),
  });
  if (!res.ok) return api.access.deny("Identity verification is unavailable.");

  const { request_uri } = await res.json();
  api.redirect.sendUserTo(`${event.secrets.DIDIT_IDV_BASE}/authorize`, {
    query: { request_uri, client_id: event.secrets.DIDIT_CLIENT_ID },
  });
};

exports.onContinuePostLogin = async (event, api) => {
  const code = event.request.query.code;
  const pending = { ...event.user.app_metadata?.idv_pkce };
  const key = txKey(event);
  const verifier = pending[key];

  // Consume the verifier so it can't be reused.
  if (key in pending) {
    delete pending[key];
    api.user.setAppMetadata("idv_pkce", pending);
  }
  if (!code || !verifier) return api.access.deny("Identity verification was not completed.");

  const res = await fetch(`${event.secrets.DIDIT_IDV_BASE}/token`, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: `Basic ${basicAuth(event)}`,
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      code_verifier: verifier,
      redirect_uri: `https://${event.request.hostname}/continue`,
    }).toString(),
  });
  if (!res.ok) return api.access.deny("Identity verification could not be confirmed.");

  const { id_token } = await res.json();
  // This token comes directly from Didit's token endpoint over TLS with client
  // authentication (the trusted back-channel), so decoding is sufficient here.
  // For defense-in-depth, verify the RS256 signature against the JWKS endpoint
  // (plus iss / aud / exp / nonce) before trusting it.
  const payload = JSON.parse(Buffer.from(id_token.split(".")[1], "base64url").toString());
  const verification = payload.verified_claims?.[0]?.verification ?? {};

  if (verification.trust_framework === "IDV-DELEGATED" && verification.assurance_level === "VERIFIED") {
    api.user.setAppMetadata("idv_status", "verified");
    api.user.setAppMetadata("idv_session", verification.verification_process ?? null);
  } else {
    api.access.deny("Identity verification failed.");
  }
};
Store the PKCE code_verifier per login transaction (keyed by event.transaction.id), not in a single app_metadata field. If a user retries or runs an overlapping login, a single shared field gets overwritten and the token exchange fails with invalid_grant.

Errors

Errors follow RFC 6749 and RFC 9126. Failed client authentication returns 401 with {"error": "invalid_client"}; an expired or replayed request_uri or authorization code returns 400 with invalid_request or invalid_grant. Authorization codes, request_uri values, and verification sessions are all single use.