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

# Secure Identity Input

> Collect the last four digits of a user's SSN for Fluz identity verification, without the digits ever touching your servers or your page's JavaScript.

> [!NOTE]
>
> This page assumes you've read [Secure Elements
> Overview](/build-a-platform/secure-elements-overview) — it covers loading the
> SDK and the shared client-token flow, both of which apply here.

Secure Identity Input mounts a single Fluz-hosted field that collects the last four digits of a user's US Social Security Number. When you submit, the frame sends those digits to Fluz along with the name, date of birth, and address your page collects. Fluz runs the same check as [Verify by SSN](/verify-customers-by-ssn) and returns the outcome. The digits stay inside the frame, so your page and servers never see them.

> [!NOTE]
>
> **You get back a verification outcome, not a token.** `onSuccess` receives
> `APPROVED`, `DECLINED`, `DUPLICATE`, or `ERROR` — never a token, an alias, or
> the digits themselves. There's no reveal for identity data either: nothing
> collected here can be read back through the SDK.

## See it live

[Fluz Secure Elements — Secure Identity Input demo](https://demo.secure.fluz.app/identity/)

The demo mints its own token and mounts the field automatically. It's wired to a stub, so it never reaches real identity verification — the four digits you enter pick the outcome: `0000` returns `DECLINED`, `1111` returns `DUPLICATE`, and anything else returns `APPROVED`. Use **Remint token & remount** to start over. [Open it in its own tab →](https://demo.secure.fluz.app/identity/)

## Before you start

Two separate gates control this capability. You need both before you can mint a token:

* **The `VERIFY_KYC` scope** on the access token you mint with. Fluz enables it on your application — see [Required scope](/user-kyc-verification#required-scope). Mint with the access token of the user you're verifying, the same token [Verify by SSN](/verify-customers-by-ssn) uses.
* **The identity-input capability**, which Fluz enables on your application by hand. It's off by default for every application, whatever scopes it holds. Send your Fluz contact a signed use case to have it turned on. Until then, minting fails with `403 capability_not_provisioned`, even when `VERIFY_KYC` is present.

Your embedding origin also has to be allow-listed, as for every Secure Elements capability — see [Prerequisites](/build-a-platform/secure-elements-overview#prerequisites).

## Mint an identity token

Call [`POST /v1/client-token`](/build-a-platform/secure-elements-overview#mint-a-client-token) with `"purpose": "identity"`:

```json theme={null}
{
  "purpose": "identity"
}
```

Like a tokenization token, it takes no resource id. It lasts 30 minutes by default, so the user has time to fill in the rest of your form.

## Render the field

```js theme={null}
const identity = renderIdentityFieldsForTokenization({
  clientToken,
  loadToken,
  frameHostOrigin: "https://staging.secure.fluz.app",
});
```

The frame holds one input that accepts exactly four digits, with `Last 4 of SSN` as its placeholder. Unlike [Secure Card Input](/build-a-platform/secure-card-input#placeholders), the placeholder isn't configurable.

| Option | Required | Details |
| :- | :- | :- |
| `clientToken` / `loadToken` | Yes | From the mint call above |
| `frameHostOrigin` | No | Same allowlisted-origin rules as [Card Reveal](/build-a-platform/card-reveal) — defaults to production if omitted |
| `style` | No | Typography only: `{ color, fontSize, fontFamily, fontWeight }` — see [Styling fields](/build-a-platform/secure-elements-overview#styling-fields). Google Fonts render here, unlike in Secure Card Input |
| `mountId` | No | Custom id for the frame's message handshake. Only needed if you mount more than one Secure Elements instance on the same page |
| `mountTimeoutMs` | No | Defaults to 10 seconds |
| `submitTimeoutMs` | No | Defaults to 15 seconds — see [Handle results](#handle-results) |

The box-model `style` properties Secure Card Input supports (`width`, `height`, `border`, `backgroundColor`) pass validation but have no effect on this frame.

## Mount it

```js theme={null}
await identity.mount(document.getElementById("ssn-field"));
```

Same shape as [Card Reveal](/build-a-platform/card-reveal#mount-it): rejects with `FluzElementsError` for `INVALID_STYLE`, `MOUNT_TIMEOUT`, or `MOUNT_FAILED` (frame failed to load, or this instance is already mounted). `frameHostOrigin` is validated synchronously when you call `renderIdentityFieldsForTokenization` — an unrecognized origin throws `INVALID_FRAME_HOST_ORIGIN` before you reach `mount()`.

The SDK doesn't size the iframe it appends, so it renders at the browser default of 300×150 until you size it from your own CSS:

```css theme={null}
#ssn-field iframe {
  width: 100%;
  height: 40px;
}
```

## Track field state

```js theme={null}
identity.onChange((state) => {
  // state: { isEmpty, isValid, isDirty }
});
```

Fires on every keystroke inside the frame. There's no `field` argument, since there's only one field, and no `brand`. `isValid` turns `true` once exactly four digits are entered — use it to gate your submit button. The digits themselves never reach your page.

## Submit

Collect the name, date of birth, and address as ordinary inputs on your own page and pass them to `submit()`. They aren't framed, so your page does handle them.

```js theme={null}
await identity.submit({
  firstName: "Jane",
  lastName: "Doe",
  dateOfBirth: "1990-01-01",
  address: {
    line1: "123 Main St",
    line2: "Apt 4", // optional
    city: "Austin",
    state: "TX",
    zipCode: "78701",
    country: "US",
  },
});
```

| Field | Required | Details |
| :- | :- | :- |
| `firstName` / `lastName` | Yes | The user's legal name, as separate fields — there's no name splitting like Secure Card Input's `cardholderName` |
| `dateOfBirth` | Yes | ISO 8601, `YYYY-MM-DD`. Fluz converts it to the `MM/DD/YYYY` format [`verifyUserInformation`](/verify-customers-by-ssn) expects, so always send ISO here |
| `address.line1` | Yes | Residential street address |
| `address.line2` | No | Apartment, suite, or unit |
| `address.city` | Yes | |
| `address.state` | Yes | Required here, unlike Secure Card Input's `billingAddress` |
| `address.zipCode` | Yes | |
| `address.country` | Yes | |

There's no `{ userAddressId }` shortcut like Secure Card Input's. To verify a user against details already on file, use [KYC Autofill](/verify-customers-by-autofill) instead — it takes no SSN digits, so it doesn't need this frame.

> [!WARNING]
>
> Check every field yourself before you call `submit()`. The frame validates
> only the four digits. A missing name, date of birth, or address field comes
> back as a generic `SUBMIT_FAILED`, with nothing to say which field it was.
> Submit the user's **residential** address — see [Address Formatting
> Requirements](/concepts/address-formatting-requirements).

> [!NOTE]
>
> `submit()` almost never rejects, and never for a decline. It only throws for
> `MOUNT_FAILED` (not mounted yet) or `SUBMIT_FAILED` ("a submit() call is
> already in progress"). Every other outcome resolves normally and arrives
> through the callbacks below.

## Handle results

```js theme={null}
identity.onSuccess((result) => {
  // result.status: "APPROVED" | "DECLINED" | "DUPLICATE" | "ERROR"
  // result.message: present on some outcomes
});

identity.onError((error) => {
  // error.code, error.message
});
```

There's no `onDeclined`. Every outcome Fluz reaches arrives through `onSuccess`, including a decline — a decline here is Fluz's answer, not a failed request. `onError` fires only when no answer came back at all.

This runs the same verification as [Verify by SSN](/verify-customers-by-ssn), so the same statuses, the same three-attempt limit, and the same `KYC_STATUS_UPDATE` webhook apply:

| Status | What to do |
| :- | :- |
| `APPROVED` | The user is verified. Unlock the relevant functionality. |
| `DECLINED` | Escalate to [document verification](/verify-customers-by-documents). Don't resubmit the same information. |
| `DUPLICATE` | The user is verified, but their SSN matches another Fluz customer. Treat them as verified and review for duplicate accounts on your side. |
| `ERROR` | No decision. With a `message`, the user is already verified or has used up their attempts. Without one, Fluz couldn't complete the check — the attempt may still have counted, so wait for the webhook before you retry. |

`onError` covers everything that isn't a verification outcome:

| Error code | Where it surfaces | Meaning |
| :- | :- | :- |
| `INVALID_FRAME_HOST_ORIGIN` | Thrown by `renderIdentityFieldsForTokenization` | `frameHostOrigin` isn't a recognized Fluz frame host |
| `INVALID_STYLE` | Rejected by `mount()` | A `style` value failed validation |
| `MOUNT_TIMEOUT` | Rejected by `mount()` | The frame didn't complete its handshake within `mountTimeoutMs` |
| `MOUNT_FAILED` | Rejected by `mount()` / thrown by `submit()` | The frame failed to load, was already mounted, or `submit()` was called before `mount()` resolved |
| `SUBMIT_FAILED` | Thrown by `submit()`, or delivered to `onError` | A second `submit()` while one is already in flight; or, via `onError`, `error.message` is one of: `VALIDATION_FAILED` (the field didn't hold exactly four digits), `COLLECT_UNAVAILABLE` (the frame never received a client token — check what you passed as `clientToken`), or `SUBMIT_FAILED` (a missing submit field, an expired session, or the submit rate limit) |
| `SUBMIT_TIMEOUT` | Delivered to `onError` | No result arrived within `submitTimeoutMs`. The verification may or may not have run — wait for the webhook before you let the user retry |

A generic `SUBMIT_FAILED` doesn't say which case you hit. One right after mount is most likely a missing field or rate limiting; one after a long idle period is most likely an expired session. For an expired session, mint a fresh client/load token pair and re-mount rather than retrying the same `submit()`.

## Cleanup

```js theme={null}
identity.destroy();
```

Removes the frame and detaches all listeners. A submit still in flight is dropped — neither `onSuccess` nor `onError` fires for it. Call this on unmount, or before minting a fresh token to retry.

## Full example

```html theme={null}
<div id="ssn-field"></div>
<input id="first-name" placeholder="First name" />
<input id="last-name" placeholder="Last name" />
<input id="date-of-birth" type="date" />
<input id="address-line1" placeholder="Street address" />
<input id="address-city" placeholder="City" />
<input id="address-state" placeholder="State" />
<input id="address-zip" placeholder="ZIP code" />
<button id="submit-button" disabled>Verify</button>

<style>
  #ssn-field iframe {
    width: 100%;
    height: 40px;
  }
</style>

<script src="https://secure-cdn.fluz.app/secure-elements/v0.5.2/index.global.js"></script>
<script>
  (async () => {
    const { renderIdentityFieldsForTokenization } = FluzSecureElements;

    const res = await fetch("/mint-identity-token", { method: "POST" });
    const { clientToken, loadToken } = await res.json();

    const identity = renderIdentityFieldsForTokenization({
      clientToken,
      loadToken,
      frameHostOrigin: "https://staging.secure.fluz.app",
      style: {
        fontFamily: "Inter",
        fontSize: "16px",
        color: "#1a1a1a",
      },
    });

    const submitButton = document.getElementById("submit-button");
    const valueOf = (id) => document.getElementById(id).value;

    identity.onChange((state) => {
      submitButton.disabled = !state.isValid;
    });
    identity.onError((error) => console.error(error.code, error.message));
    identity.onSuccess((result) => console.log(result.status, result.message));

    await identity.mount(document.getElementById("ssn-field"));

    submitButton.addEventListener("click", async () => {
      submitButton.disabled = true;
      try {
        await identity.submit({
          firstName: valueOf("first-name"),
          lastName: valueOf("last-name"),
          dateOfBirth: valueOf("date-of-birth"), // <input type="date"> already yields YYYY-MM-DD
          address: {
            line1: valueOf("address-line1"),
            city: valueOf("address-city"),
            state: valueOf("address-state"),
            zipCode: valueOf("address-zip"),
            country: "US",
          },
        });
      } catch (error) {
        console.error(error.code, error.message);
      } finally {
        submitButton.disabled = false;
      }
    });
  })();
</script>
```

`/mint-identity-token` is your own backend route — the one that calls `POST /v1/client-token` with `"purpose": "identity"` and the access token of the user being verified.

## Next steps

> **[Secure Elements Overview](/build-a-platform/secure-elements-overview)**
>
> Token minting, SDK loading, styling, and CSP.

&#x20;

> **[Verify by SSN](/verify-customers-by-ssn)**
>
> The verification this element runs, called directly from your server.

&#x20;

> **[Secure Card Input](/build-a-platform/secure-card-input)**
>
> Collect a physical card and add it as a payment method.

> **[Live demo](https://demo.secure.fluz.app/identity/)**
>
> Try the identity field running against staging.


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