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

# Step-up authentication

> Require fresh authentication before a sensitive action — signing, exporting a key, or updating a profile — scoped to what the action needs.

## What you'll build

Step-up authentication gates a **sensitive action** behind fresh authentication, scoped to a [`TokenScope`](#available-scopes) such as `wallet:sign` or `wallet:export`. You'll:

1. **Check** whether the action's scope currently needs step-up.
2. **Re-authenticate** the user, passing that scope as `requestedScopes`. This mints a short-lived **elevated access token** for the scope.
3. **Perform** the action — the SDK finds and consumes the matching elevated token automatically.

Step-up is **independent of how the user re-authenticates**. Email OTP, SMS OTP, an authenticator app, a passkey, a recovery code, or a connected wallet all satisfy it the same way — you pass `requestedScopes` on the verify/authenticate call. This page uses **email OTP** for the full walkthrough, then shows the one-line change for every other method. If your backend authorizes the action instead of the user, an [externally-issued JWT](#server-authorized-step-up-external-jwt) elevates the session server-side.

<Note>
  This is not the same as MFA. MFA (see [MFA enrollment & management](/docs/javascript/building-ui/mfa-enrollment-management)) is one *credential type* a user can present. Step-up is the scope-based elevation flow — your project settings decide whether a given scope requires it, and any credential the user has can satisfy it.
</Note>

## Prerequisites

* The user is already signed in. Step-up elevates an existing session; it is not a sign-in flow.

## Checking whether step-up is required

This page gates **signing** as the running example, so every call below checks the `wallet:sign` scope. Swap it for whichever [scope](#available-scopes) matches the action you're protecting — the flow is identical.

`checkStepUpAuth({ scope })` returns `{ isRequired, credentials, defaultCredentialId }`. It resolves to `isRequired: false` immediately when a valid elevated token for the scope already exists; otherwise it asks the backend, which factors in your project settings. `credentials` lists the methods this user can use to satisfy the challenge, and `defaultCredentialId` is the one to pre-select in your UI (the sign-in credential, or the default / most-recently-added MFA device) — use it instead of blindly taking the first credential.

<Tabs>
  <Tab title="TypeScript">
    ```typescript theme={"system"}
    import { checkStepUpAuth, TokenScope } from '@dynamic-labs-sdk/client';

    const { isRequired, credentials } = await checkStepUpAuth({
      scope: TokenScope.Walletsign,
    });

    if (isRequired) {
      // Re-authenticate before signing (see below).
    }
    ```
  </Tab>

  <Tab title="React">
    ```tsx theme={"system"}
    import { useCheckStepUpAuth } from '@dynamic-labs-sdk/react-hooks';
    import { TokenScope } from '@dynamic-labs-sdk/client';

    const { data } = useCheckStepUpAuth({ scope: TokenScope.Walletsign });

    if (data?.isRequired) {
      // Render your step-up UI from data.credentials,
      // pre-selecting data.defaultCredentialId.
    }
    ```
  </Tab>
</Tabs>

## Full example: elevate with email OTP

Check the scope, re-authenticate only when needed, then perform the action. The sign call needs no elevated token passed to it — it reads the one that `verifyOTP` just minted.

<Tabs>
  <Tab title="TypeScript">
    ```typescript theme={"system"}
    import {
      checkStepUpAuth,
      sendEmailOTP,
      verifyOTP,
      TokenScope,
    } from '@dynamic-labs-sdk/client';

    // `promptForCode` and `sign` are your implementations.
    async function signWithStepUp(email: string, sign: () => Promise<void>) {
      const { isRequired } = await checkStepUpAuth({ scope: TokenScope.Walletsign });

      if (isRequired) {
        const { verificationUUID } = await sendEmailOTP({ email });
        const code = await promptForCode();

        // Passing `requestedScopes` mints an elevated token for that scope
        // instead of only verifying the code.
        await verifyOTP({
          otpVerification: { email, verificationUUID },
          verificationToken: code,
          requestedScopes: [TokenScope.Walletsign],
        });
      }

      await sign();
    }
    ```
  </Tab>

  <Tab title="React">
    ```tsx theme={"system"}
    import { useSendEmailOTP, useVerifyOTP } from '@dynamic-labs-sdk/react-hooks';
    import { checkStepUpAuth, TokenScope } from '@dynamic-labs-sdk/client';

    function SignButton({ email, sign }: { email: string; sign: () => Promise<void> }) {
      const { mutateAsync: sendEmailOTP } = useSendEmailOTP();
      const { mutateAsync: verifyOTP } = useVerifyOTP();

      const handleSign = async () => {
        const { isRequired } = await checkStepUpAuth({ scope: TokenScope.Walletsign });

        if (isRequired) {
          const { verificationUUID } = await sendEmailOTP({ email });
          const code = await promptForCode(); // your UI

          await verifyOTP({
            otpVerification: { email, verificationUUID },
            verificationToken: code,
            requestedScopes: [TokenScope.Walletsign],
          });
        }

        await sign();
      };

      return (
        <button type="button" onClick={handleSign}>
          Sign
        </button>
      );
    }
    ```
  </Tab>
</Tabs>

## Use any auth method

Every credential type takes the same `requestedScopes` argument. Swap the re-authentication step above for whichever the user has — the check-then-act structure stays identical. Each snippet below is just that one call.

<Tabs>
  <Tab title="TypeScript">
    ```typescript theme={"system"}
    import {
      verifyOTP,
      authenticateTotpMfaDevice,
      authenticatePasskeyMFA,
      authenticateMfaRecoveryCode,
      verifyWalletAccount,
      TokenScope,
    } from '@dynamic-labs-sdk/client';

    const requestedScopes = [TokenScope.Walletsign];

    // Email / SMS OTP
    await verifyOTP({ otpVerification, verificationToken: code, requestedScopes });

    // Authenticator app (TOTP)
    await authenticateTotpMfaDevice({ deviceId, code, requestedScopes });

    // Passkey
    await authenticatePasskeyMFA({ requestedScopes });

    // MFA recovery code
    await authenticateMfaRecoveryCode({ code, requestedScopes });

    // Connected wallet (sign-to-verify)
    await verifyWalletAccount({ walletAccount, requestedScopes });
    ```
  </Tab>

  <Tab title="React">
    ```tsx theme={"system"}
    import {
      useVerifyOTP,
      useAuthenticateTotpMfaDevice,
      useAuthenticatePasskeyMFA,
      useAuthenticateMfaRecoveryCode,
      useVerifyWalletAccount,
    } from '@dynamic-labs-sdk/react-hooks';
    import { TokenScope } from '@dynamic-labs-sdk/client';

    const requestedScopes = [TokenScope.Walletsign];

    const { mutateAsync: verifyOTP } = useVerifyOTP();
    const { mutateAsync: authenticateTotp } = useAuthenticateTotpMfaDevice();
    const { mutateAsync: authenticatePasskey } = useAuthenticatePasskeyMFA();
    const { mutateAsync: authenticateRecoveryCode } = useAuthenticateMfaRecoveryCode();
    const { mutateAsync: verifyWalletAccount } = useVerifyWalletAccount();

    // Each mutate takes the same args as its client function, e.g.:
    await verifyOTP({ otpVerification, verificationToken: code, requestedScopes });
    await authenticateTotp({ deviceId, code, requestedScopes });
    await authenticatePasskey({ requestedScopes });
    await authenticateRecoveryCode({ code, requestedScopes });
    await verifyWalletAccount({ walletAccount, requestedScopes });
    ```
  </Tab>
</Tabs>

## Server-authorized step-up (external JWT)

The methods above elevate the session from something **the user** does in the browser. When **your backend** is the one authorizing the action — for example a server-side approval workflow, or trust already established by your own system — you elevate with an assertion JWT instead. Here the scope is not passed as `requestedScopes`; it travels **inside the signed JWT**, so the browser can't widen it.

How it works:

1. Your backend mints a short-lived JWT and signs it with the key registered at your environment's **external auth JWKS URL**. The JWT carries these claims:
   * `sub` — the user the elevation is for.
   * `scope` — the [`TokenScope`](#available-scopes) being granted (e.g. `wallet:sign`). **This is what determines the elevated scope.**
   * `jti` — a unique id so the assertion can only be redeemed once.
   * `exp` — a short expiry.
2. Hand that JWT to the SDK. It verifies the signature against your JWKS, then mints and stores the elevated access token for the `scope` claim.
3. Perform the action — the SDK consumes the matching elevated token exactly as with the user-driven methods.

<Tabs>
  <Tab title="TypeScript">
    ```typescript theme={"system"}
    import { requestExternalAuthElevatedToken } from '@dynamic-labs-sdk/client';

    // `externalJwt` is minted and signed by your backend with a `scope` claim.
    await requestExternalAuthElevatedToken({ externalJwt });

    await sign();
    ```
  </Tab>

  <Tab title="React">
    ```tsx theme={"system"}
    import { useRequestExternalAuthElevatedToken } from '@dynamic-labs-sdk/react-hooks';

    const { mutateAsync: requestExternalAuthElevatedToken } =
      useRequestExternalAuthElevatedToken();

    // `externalJwt` is minted and signed by your backend with a `scope` claim.
    await requestExternalAuthElevatedToken({ externalJwt });

    await sign();
    ```
  </Tab>
</Tabs>

<Note>
  Because the scope is baked into the signed assertion, you don't call `checkStepUpAuth` first for this path — your backend has already decided the elevation is warranted.
</Note>

## Available scopes

Request the scope that matches the action you're gating. Common SDK scopes:

| `TokenScope`       | Value               | Gate before                      |
| ------------------ | ------------------- | -------------------------------- |
| `Walletsign`       | `wallet:sign`       | Signing a message or transaction |
| `Walletexport`     | `wallet:export`     | Exporting a private key          |
| `Walletdelegate`   | `wallet:delegate`   | Granting delegated access        |
| `Walletrestore`    | `wallet:restore`    | Restoring a wallet               |
| `Walletdelete`     | `wallet:delete`     | Deleting a wallet                |
| `Userupdate`       | `user:update`       | Updating the user's profile      |
| `Credentiallink`   | `credential:link`   | Linking a new credential         |
| `Credentialupdate` | `credential:update` | Updating a credential            |
| `Credentialunlink` | `credential:unlink` | Unlinking a credential           |

## Handling errors

Step-up adds no error types of its own. Because any credential can satisfy the challenge, the errors you handle are the ones the **re-authentication method you chose** already throws — a wrong code, a rate-limited attempt, a rejected wallet signature — handled exactly as they are outside step-up. For example, the MFA methods (`authenticateTotpMfaDevice`, `authenticatePasskeyMFA`, `authenticateMfaRecoveryCode`) throw `MfaInvalidOtpError` on a wrong or expired code and `MfaRateLimitedError` after too many attempts; ask the user to re-enter or wait, and don't run the action.

Two situations are specific to step-up itself:

| Situation                                                            | What to do                                                                                                                                 |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `checkStepUpAuth` returns empty `credentials`                        | The user has no way to elevate — send them to enroll one (e.g. [MFA enrollment](/docs/javascript/building-ui/mfa-enrollment-management)) first. |
| `requestExternalAuthElevatedToken` throws `InvalidExternalAuthError` | Your backend's assertion failed verification — check its `scope`, `exp`, `jti`, and signature.                                             |

<Note>
  `checkStepUpAuth` defaults to `isRequired: true` if the check itself fails, so a network error never silently skips the challenge. Gate the action on the check result, not on the UI.
</Note>

## See also

* [MFA enrollment & management](/docs/javascript/building-ui/mfa-enrollment-management) — enroll the authenticator/passkey credentials a challenge can use
* [Transaction confirmation & simulation](/docs/javascript/building-ui/transaction-confirmation) — the `wallet:sign` action you can gate
* [Wallet backup & recovery](/docs/javascript/building-ui/wallet-backup-recovery) — the `wallet:export` action you can gate
