> ## Documentation Index
> Fetch the complete documentation index at: https://docs.air3.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Quickstart: Verifying your first credential

> Configure an AIR verification program, obtain user consent, and verify the SD-JWT presentation on your backend with AIR Kit.

This guide adds credential verification to your web app. Your verification
program defines the accepted credentials and requested claims. AIR Kit handles
credential discovery, the consent screen, and presentation generation. Your
backend issues a nonce and verifies the returned SD-JWT presentation.

## Before you start

You need:

* An AIR Developer Dashboard account and Partner ID, with verifier functionality enabled
* A web app with its origin registered in the Dashboard
* A public HTTPS JWKS endpoint registered for your Partner ID
* A server endpoint that signs short-lived Partner JWTs with `scope: "verify"`
* A test holder with a matching credential from an issuer accepted by your program,
  or access to that issuer's claim flow
* A supported Node.js version for your web framework and AIR Kit

You do not need to operate an issuer backend or database to verify another
issuer's credentials. The [issuance quickstart](/get-started/quickstarts/issue-credentials)
is optional. AIR login is required for this SDK flow; the code below includes it.

## Step 1: Configure a verification program

1. Open the [Sandbox Developer Dashboard](https://developers.sandbox.air3.com/dashboard)
   and copy your Partner ID from **Account → General Settings**.
2. Under **Verifier → Programs**, create a program and select the accepted schema
   and issuer configuration.
3. Select `SD_JWT_VC` as the accepted proof type and choose the claims to request.
4. Publish or apply the program so it is active, and copy its verification program ID.

Predicate checks such as "age greater than 18" without revealing the value, and
on-chain verification, need Iden3 credentials. See
[Iden3 credentials](/products/identity/iden3-credentials).

## Step 2: Configure Partner authentication

The code below uses a Next.js app. Keep the private key on the server, including
during local development.

```bash theme={null}
npm i @mocanetwork/airkit jose
```

Set your app's environment variables:

```bash theme={null}
# Browser-safe
NEXT_PUBLIC_PARTNER_ID=<dashboard-partner-id>
NEXT_PUBLIC_VERIFY_PROGRAM_ID=<verification-program-id>

# Server-only
PARTNER_PRIVATE_KEY=<pkcs8-private-key-body-without-pem-markers>
PARTNER_PUBLIC_KEY=<public-key-body-without-pem-markers>
SIGNING_ALGORITHM=RS256
```

Verification fails until AIR can fetch your JWKS. If you haven't set one up for
this Partner ID, follow [JWKS endpoint](/get-started/authentication/jwks-endpoint). If you already
issue credentials with the same Partner ID, reuse that JWKS and key pair.

Create a server endpoint for verification tokens:

```ts app/api/verification-token/route.ts theme={null}
import { randomBytes } from "node:crypto";
import { NextResponse } from "next/server";
import { importPKCS8, SignJWT } from "jose";

export async function POST() {
  // Apply your app's session and access checks before issuing a token.
  const partnerId = process.env.NEXT_PUBLIC_PARTNER_ID;
  const privateKeyBody = process.env.PARTNER_PRIVATE_KEY;

  if (!partnerId || !privateKeyBody) {
    return NextResponse.json(
      { error: "Missing Partner JWT configuration" },
      { status: 500 },
    );
  }

  try {
    const pem = privateKeyBody.includes("BEGIN PRIVATE KEY")
      ? privateKeyBody
      : `-----BEGIN PRIVATE KEY-----\n${privateKeyBody.trim()}\n-----END PRIVATE KEY-----`;
    const key = await importPKCS8(pem, "RS256");
    const token = await new SignJWT({ partnerId, scope: "verify" })
      .setProtectedHeader({ alg: "RS256", kid: partnerId, typ: "JWT" })
      .setIssuedAt()
      .setExpirationTime("5m")
      .sign(key);

    // One-time nonce for the key-binding JWT. Store it against the user's
    // session so /api/verify-presentation can check and delete it.
    const nonce = randomBytes(16).toString("base64url");
    // await saveNonceForSession(session, nonce);

    return NextResponse.json({ token, nonce });
  } catch {
    return NextResponse.json(
      { error: "Unable to sign verification token" },
      { status: 500 },
    );
  }
}
```

This example uses your Partner ID as the JWKS key's `kid`. If your registered
key uses a different `kid`, use that value in the token header.

## Step 3: Initialize AIR Kit and start verification

Call this helper from a browser event handler, such as your **Verify** button:

```ts lib/verify-credential.ts theme={null}
import { AirService, BUILD_ENV } from "@mocanetwork/airkit";

let servicePromise: Promise<AirService> | undefined;

function getAirService(): Promise<AirService> {
  servicePromise ??= (async () => {
    const partnerId = process.env.NEXT_PUBLIC_PARTNER_ID;
    if (!partnerId) throw new Error("Partner ID is required");

    const service = new AirService({ partnerId });
    await service.init({ buildEnv: BUILD_ENV.SANDBOX });
    return service;
  })().catch((error) => {
    servicePromise = undefined;
    throw error;
  });
  return servicePromise;
}

export async function verifyCredential() {
  const programId = process.env.NEXT_PUBLIC_VERIFY_PROGRAM_ID;
  if (!programId) throw new Error("Verification program ID is required");

  const service = await getAirService();
  if (!service.isLoggedIn) await service.login();

  // Your server returns a Partner JWT and a fresh nonce stored in the session
  const response = await fetch("/api/verification-token", { method: "POST" });
  const body = (await response.json()) as {
    token?: string;
    nonce?: string;
    error?: string;
  };
  if (!response.ok || !body.token || !body.nonce) {
    throw new Error(body.error ?? "Unable to obtain verification token");
  }

  return service.verifyCredential({
    authToken: body.token,
    programId,
    fieldsToDisclose: ["historical_amount"], // or "*" for every field
    nonce: body.nonce,
  });
}
```

The nonce needs at least 128 bits of randomness, as in the token route above. If your flow needs to redirect users to an issuer when they lack
a credential, add the optional `redirectUrl` pointing to that issuer's claim page.

## Step 4: Obtain consent and handle the result

During verification, AIR Kit:

1. Finds matching credentials. If none exist, the user needs to complete an
   accepted issuer's issuance flow before verification can succeed.
2. Fetches and decrypts the credential on the holder's device.
3. Shows the requested data and asks the user to consent.
4. Records consent and generates the presentation. For a holder-bound
   credential, the holder signs a key-binding JWT over your nonce.
5. Returns the outcome to your app.

Handle both returned statuses and errors, including a cancelled flow. Grant
access only after a successful result appropriate to your application's trust
requirements.

```ts theme={null}
import { verifyCredential } from "@/lib/verify-credential";

try {
  const result = await verifyCredential();
  if (result.status === "Compliant") {
    // Verify on your backend before granting access
    const check = await fetch("/api/verify-presentation", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        verifiablePresentation: result.verifiablePresentation,
      }),
    });
    if (!check.ok) throw new Error("Presentation rejected");
  } else {
    console.log("Verification did not pass:", result.status);
  }
} catch {
  console.log("Verification was cancelled or could not complete. Allow a retry.");
}
```

The `Compliant` check runs in the browser, and the presentation wrapper is not
signed. Your `/api/verify-presentation` route must verify the SD-JWT inside it:
issuer signature, disclosures, expiry, credential type, and the key-binding JWT
against your nonce and `programId`. Then it deletes the nonce. Follow
[Verify SD-JWT on your backend](/products/identity/verify-sd-jwt), which
includes a reference implementation.

## Step 5: Test the integration

Test with a matching credential, a credential that fails the requested condition,
and a user who declines consent. Confirm your app handles missing credentials,
expired tokens, and failed requests without granting access.

Also confirm your backend rejects a replayed presentation (same nonce twice), a
presentation made for another `programId`, and a presentation with a disclosure
removed.

## Troubleshooting

| Symptom | Check |
| - | - |
| Partner JWT rejected | Public JWKS URL, matching `kid` and key pair, `scope: "verify"`, and token expiry |
| No matching credential | Program schema and accepted issuers, holder's credentials, and issuer claim flow |
| Backend rejects with `Key Binding JWT not exist` | The credential has a holder key but the KB-JWT is missing. Check you passed `nonce` |
| Backend rejects with `Invalid Nonce` | The nonce does not match this session's stored nonce, or it was already used |
| Program unavailable | Correct environment, active program, and enabled verifier functionality |

## Next steps

* [Verification reference](/products/identity/verify)
* [Verify SD-JWT on your backend](/products/identity/verify-sd-jwt)
* [Architecture and data flow](/technicals/architecture)
* [Credential issuance quickstart](/get-started/quickstarts/issue-credentials), if you also operate an issuer


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.