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

# Server-side MFA

> Enroll users in MFA and verify credentials from your server. Manage and use MFA tokens.

Your app's server can enroll users in MFA and verify them with the Privy REST API or a server SDK. Server-side MFA is useful for apps that authenticate users on their own backend, such as apps using [JWT-based authentication](https://docs.privy.io/authentication/user-authentication/jwt-based-auth/overview.md).

Every MFA method follows the same flow:

**1. Start the MFA ceremony**

Call the MFA method's `init` route to begin MFA enrollment or verification. For SMS, Privy sends
a 6-digit code to the user. For passkeys, Privy returns WebAuthn options for the user's browser.

**2. Complete the MFA ceremony**

Send the user's code or passkey credential to the MFA method's `verify` route to complete MFA
enrollment or verification. Privy returns an [MFA token](#mfa-token).

**3. Use the MFA token**

Send the MFA token on requests that require MFA, such as authorizing a wallet action.

## Authentication

Every server-side MFA request needs three credentials:

* Your app ID and app secret, as Basic auth. See [REST API setup](https://docs.privy.io/basics/rest-api/setup.md).
* The `privy-app-id` header.
* The user's access token or custom auth JWT, in the `privy-user-token` header.

Server SDKs send your app credentials from the client configuration. Pass the user's JWT on each MFA call.

The JWT must belong to the user in the request path. Otherwise, Privy rejects the request with a `401`.

**REST API**

```bash theme={"system"}
curl -X POST "https://api.privy.io/v1/users/$USER_ID/mfa/sms/verification/init" \
  -u "$PRIVY_APP_ID:$PRIVY_APP_SECRET" \
  -H "privy-app-id: $PRIVY_APP_ID" \
  -H "privy-user-token: $USER_JWT"
```

**NodeJS**

```ts theme={"system"}
import {PrivyClient} from '@privy-io/node';

const privy = new PrivyClient({
  appId: process.env.PRIVY_APP_ID!,
  appSecret: process.env.PRIVY_APP_SECRET!
});

const userId = 'did:privy:cm9x2k7d1000abc123';
const userJwt = 'insert-user-jwt';

await privy.users().mfa.sms.verification.init(userId, {user_jwt: userJwt});
```

The remaining examples on this page reuse `privy`, `userId`, and `userJwt`.

## SMS

With SMS MFA, Privy texts a 6-digit code to the user's phone number. Each code works once.

> [!NOTE]
>
> Users with a phone number login account cannot enroll in SMS MFA. A phone number can secure login
> or MFA, but not both.

### Enrollment

To enroll a user in SMS MFA, call `init` with the user's phone number. Privy sends an enrollment code to that number. Call `verify` with the code the user received to complete MFA enrollment.

**REST API**

```bash theme={"system"}
curl -X POST "https://api.privy.io/v1/users/$USER_ID/mfa/sms/enrollment/init" \
  -u "$PRIVY_APP_ID:$PRIVY_APP_SECRET" \
  -H "privy-app-id: $PRIVY_APP_ID" \
  -H "privy-user-token: $USER_JWT" \
  -H "Content-Type: application/json" \
  -d '{"phone_number": "+14155550123"}'

# After the user receives the code
curl -X POST "https://api.privy.io/v1/users/$USER_ID/mfa/sms/enrollment/verify" \
  -u "$PRIVY_APP_ID:$PRIVY_APP_SECRET" \
  -H "privy-app-id: $PRIVY_APP_ID" \
  -H "privy-user-token: $USER_JWT" \
  -H "Content-Type: application/json" \
  -d '{"code": "123456"}'
```

**NodeJS**

```ts {skip-check} theme={"system"}
await privy.users().mfa.sms.enrollment.init(userId, {
  phone_number: '+14155550123',
  user_jwt: userJwt
});

// After the user receives the code
const {token} = await privy.users().mfa.sms.enrollment.verify(userId, {
  code: '123456',
  user_jwt: userJwt
});
```

If the user already has MFA enrolled, enrollment `init` requires a valid MFA token. See [adding or replacing a method](#adding-or-replacing-a-method).

### Verification

To verify a user's SMS MFA credential, call `init` to send a code to the user's enrolled phone number. Call `verify` with the code the user received to complete MFA verification.

**REST API**

```bash theme={"system"}
curl -X POST "https://api.privy.io/v1/users/$USER_ID/mfa/sms/verification/init" \
  -u "$PRIVY_APP_ID:$PRIVY_APP_SECRET" \
  -H "privy-app-id: $PRIVY_APP_ID" \
  -H "privy-user-token: $USER_JWT"

# After the user receives the code
curl -X POST "https://api.privy.io/v1/users/$USER_ID/mfa/sms/verification/verify" \
  -u "$PRIVY_APP_ID:$PRIVY_APP_SECRET" \
  -H "privy-app-id: $PRIVY_APP_ID" \
  -H "privy-user-token: $USER_JWT" \
  -H "Content-Type: application/json" \
  -d '{"code": "123456"}'
```

**NodeJS**

```ts {skip-check} theme={"system"}
await privy.users().mfa.sms.verification.init(userId, {user_jwt: userJwt});

// After the user receives the code
const {token} = await privy.users().mfa.sms.verification.verify(userId, {
  code: '123456',
  user_jwt: userJwt
});
```

## TOTP

With TOTP MFA, the user reads a 6-digit code from an authenticator app, like Google Authenticator or Authy. Each code works once.

### Enrollment

To enroll a user in TOTP MFA, call `init` to create a secret. It returns the secret and an `otpauth://` URI:

```json theme={"system"}
{
  "options": {
    "secret": "JBSWY3DPEHPK3PXP",
    "uri": "otpauth://totp/MyApp?secret=JBSWY3DPEHPK3PXP&issuer=MyApp"
  }
}
```

Show the URI to the user as a QR code, or show the secret for manual entry. Call `verify` with a code from the user's authenticator app to complete MFA enrollment.

**REST API**

```bash theme={"system"}
curl -X POST "https://api.privy.io/v1/users/$USER_ID/mfa/totp/enrollment/init" \
  -u "$PRIVY_APP_ID:$PRIVY_APP_SECRET" \
  -H "privy-app-id: $PRIVY_APP_ID" \
  -H "privy-user-token: $USER_JWT"

# After the user adds options.uri to their authenticator app
curl -X POST "https://api.privy.io/v1/users/$USER_ID/mfa/totp/enrollment/verify" \
  -u "$PRIVY_APP_ID:$PRIVY_APP_SECRET" \
  -H "privy-app-id: $PRIVY_APP_ID" \
  -H "privy-user-token: $USER_JWT" \
  -H "Content-Type: application/json" \
  -d '{"code": "123456"}'
```

**NodeJS**

```ts {skip-check} theme={"system"}
const {options} = await privy.users().mfa.totp.enrollment.init(userId, {user_jwt: userJwt});

// After the user adds options.uri to their authenticator app
const {token} = await privy.users().mfa.totp.enrollment.verify(userId, {
  code: '123456',
  user_jwt: userJwt
});
```

If the user already has MFA enrolled, enrollment `init` requires a valid MFA token. See [adding or replacing a method](#adding-or-replacing-a-method).

### Verification

To verify a user's TOTP MFA credential, call `verify` with a code from the user's authenticator app. TOTP has no `init` call, since the authenticator app generates codes.

**REST API**

```bash theme={"system"}
curl -X POST "https://api.privy.io/v1/users/$USER_ID/mfa/totp/verification/verify" \
  -u "$PRIVY_APP_ID:$PRIVY_APP_SECRET" \
  -H "privy-app-id: $PRIVY_APP_ID" \
  -H "privy-user-token: $USER_JWT" \
  -H "Content-Type: application/json" \
  -d '{"code": "123456"}'
```

**NodeJS**

```ts {skip-check} theme={"system"}
const {token} = await privy.users().mfa.totp.verification.verify(userId, {
  code: '123456',
  user_jwt: userJwt
});
```

## Passkeys

With passkey MFA, the user verifies with a passkey in their browser. Your app's server gets WebAuthn options from Privy and passes them to the browser. The browser runs the WebAuthn ceremony and returns a credential, which your app's server sends to Privy.

Both `init` calls take a `relying_party`: the origin where the user's browser runs the ceremony. If your app has [allowed domains](https://docs.privy.io/recipes/dashboard/allowed-domains.md), the origin must be one of them.

> [!NOTE]
>
> Privy uses snake\_case keys in `options` and `credential`, such as `pub_key_cred_params` and
> `raw_id`. Browser WebAuthn libraries, like `@simplewebauthn/browser`, use camelCase. Convert the
> keys in both directions. Note that `client_data_json` maps to `clientDataJSON`.

### Enrollment

To enroll a user in passkey MFA, call `init` to get WebAuthn registration options. In the browser, pass the options to `navigator.credentials.create()` or `startRegistration()` from `@simplewebauthn/browser`. Call `verify` with the resulting credential to complete MFA enrollment.

**REST API**

```bash theme={"system"}
curl -X POST "https://api.privy.io/v1/users/$USER_ID/mfa/passkey/enrollment/init" \
  -u "$PRIVY_APP_ID:$PRIVY_APP_SECRET" \
  -H "privy-app-id: $PRIVY_APP_ID" \
  -H "privy-user-token: $USER_JWT" \
  -H "Content-Type: application/json" \
  -d '{"relying_party": "https://app.example.com"}'

# After the browser creates a passkey from options
curl -X POST "https://api.privy.io/v1/users/$USER_ID/mfa/passkey/enrollment/verify" \
  -u "$PRIVY_APP_ID:$PRIVY_APP_SECRET" \
  -H "privy-app-id: $PRIVY_APP_ID" \
  -H "privy-user-token: $USER_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "credential": {
      "id": "<credential-id>",
      "raw_id": "<credential-id>",
      "type": "public-key",
      "response": {
        "client_data_json": "<client-data-json>",
        "attestation_object": "<attestation-object>"
      },
      "client_extension_results": {}
    }
  }'
```

**NodeJS**

```ts {skip-check} theme={"system"}
const {options} = await privy.users().mfa.passkey.enrollment.init(userId, {
  relying_party: 'https://app.example.com',
  user_jwt: userJwt
});

// After the browser creates a passkey from options
const {token} = await privy.users().mfa.passkey.enrollment.verify(userId, {
  credential,
  user_jwt: userJwt
});
```

If the user already has MFA enrolled, enrollment `init` requires a valid MFA token. See [adding or replacing a method](#adding-or-replacing-a-method).

### Verification

To verify a user's passkey MFA credential, call `init` to get WebAuthn authentication options. In the browser, pass the options to `navigator.credentials.get()` or `startAuthentication()` from `@simplewebauthn/browser`. Call `verify` with the resulting credential to complete MFA verification.

**REST API**

```bash theme={"system"}
curl -X POST "https://api.privy.io/v1/users/$USER_ID/mfa/passkey/verification/init" \
  -u "$PRIVY_APP_ID:$PRIVY_APP_SECRET" \
  -H "privy-app-id: $PRIVY_APP_ID" \
  -H "privy-user-token: $USER_JWT" \
  -H "Content-Type: application/json" \
  -d '{"relying_party": "https://app.example.com"}'

# After the browser signs the challenge in options
curl -X POST "https://api.privy.io/v1/users/$USER_ID/mfa/passkey/verification/verify" \
  -u "$PRIVY_APP_ID:$PRIVY_APP_SECRET" \
  -H "privy-app-id: $PRIVY_APP_ID" \
  -H "privy-user-token: $USER_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "credential": {
      "id": "<credential-id>",
      "raw_id": "<credential-id>",
      "type": "public-key",
      "response": {
        "client_data_json": "<client-data-json>",
        "authenticator_data": "<authenticator-data>",
        "signature": "<signature>"
      },
      "client_extension_results": {}
    }
  }'
```

**NodeJS**

```ts {skip-check} theme={"system"}
const {options} = await privy.users().mfa.passkey.verification.init(userId, {
  relying_party: 'https://app.example.com',
  user_jwt: userJwt
});

// After the browser signs the challenge in options
const {token} = await privy.users().mfa.passkey.verification.verify(userId, {
  credential,
  user_jwt: userJwt
});
```

## MFA token

Every `verify` call returns the same response:

```json theme={"system"}
{
  "token": "eyJhbGciOiJFUzI1NiIs...",
  "expires_at": 1761170400,
  "user": {
    "id": "did:privy:cm9x2k7d1000abc123",
    "linked_accounts": [...],
    "mfa_methods": [{"type": "sms", "verified_at": 1761169800}]
  }
}
```

**token** `string`

The MFA token, a JWT proving the user completed MFA.

**expires\_at** `number`

Unix timestamp in seconds when the MFA token expires. The app's [MFA verification
duration](https://dashboard.privy.io/admin/mfa-cache-duration) sets the lifetime.

**user** `object`

The current [user object](https://docs.privy.io/user-management/users/the-user-object.md), including `mfa_methods`.

Completing MFA enrollment also sends the [`mfa.enabled` webhook](https://docs.privy.io/api-reference/webhooks/mfa/enabled).

### Authorizing a wallet action

Your app's server authorizes wallet actions on the user's wallets with a [user key](https://docs.privy.io/controls/authorization-keys/keys/create/user/request.md). When the user has MFA enrolled, Privy requires an MFA token to issue that key.

With a server SDK, pass the MFA token with the user's JWT in the authorization context. The SDK requests the user key and signs the wallet action. With the REST API, send the MFA token in the `privy-mfa-token` header when requesting the user key.

**REST API**

```bash theme={"system"}
curl -X POST "https://api.privy.io/v1/wallets/authenticate" \
  -u "$PRIVY_APP_ID:$PRIVY_APP_SECRET" \
  -H "privy-app-id: $PRIVY_APP_ID" \
  -H "privy-mfa-token: $MFA_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"user_jwt\": \"$USER_JWT\"}"
```

**NodeJS**

```ts {skip-check} theme={"system"}
const {signature} = await privy
  .wallets()
  .ethereum()
  .signMessage('insert-wallet-id', {
    message: 'Hello, Ethereum.',
    authorization_context: {
      user_auth: [{user_jwt: userJwt, mfa_token: token}]
    }
  });
```

### Adding or replacing a method

When the user already has MFA enrolled, enrollment `init` requires an MFA token issued in the last 30 seconds. To add or replace a method, complete MFA verification first. Start MFA enrollment right away with the new token.

A new SMS enrollment replaces the phone number. A new TOTP enrollment replaces the secret, so codes from the old authenticator app stop working. A new passkey enrollment adds a passkey.

**REST API**

```bash theme={"system"}
curl -X POST "https://api.privy.io/v1/users/$USER_ID/mfa/sms/enrollment/init" \
  -u "$PRIVY_APP_ID:$PRIVY_APP_SECRET" \
  -H "privy-app-id: $PRIVY_APP_ID" \
  -H "privy-user-token: $USER_JWT" \
  -H "privy-mfa-token: $MFA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"phone_number": "+14155550123"}'
```

**NodeJS**

```ts {skip-check} theme={"system"}
const {token} = await privy.users().mfa.totp.verification.verify(userId, {
  code: '123456',
  user_jwt: userJwt
});

await privy.users().mfa.sms.enrollment.init(userId, {
  phone_number: '+14155550123',
  user_jwt: userJwt,
  mfa_token: token
});
```

## Errors

| Status | Cause |
| - | - |
| `400` | The request is malformed, or the passkey credential is invalid. |
| `400` | The user has a phone number login account and cannot enroll in SMS MFA. |
| `401` | The JWT is invalid, or it belongs to a different user. |
| `401` | The code is invalid or expired, or no ceremony is in progress. |
| `401` | The user already has MFA, and the MFA token is missing or older than 30 seconds. |
| `403` | The MFA method is not enabled for the app. |
| `403` | The user has not enrolled in the MFA method they are verifying. |
| `403` | The user is a guest user. |
| `403` | The passkey `relying_party` is not one of the app's allowed domains. |
| `429` | Too many codes were sent or tried, or the TOTP code was already used. |

Server SDKs raise these as errors with the same status codes.

For request and response schemas, see the [MFA API reference](https://docs.privy.io/api-reference/users/mfa/sms/enrollment/init.md).
