> ## 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](https://docs.dynamic.xyz/docs/javascript/building-ui/mfa-enrollment-management.md)) 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.

## 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.

> [tab] 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] 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.
> }
> ```

## 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.

> [tab] 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] 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>
>   );
> }
> ```

`sendEmailOTP` also accepts an optional `purpose` — the same `TokenScope` you're elevating for — so the verification email describes the operation (for example a key export) instead of a generic sign-in:

```typescript theme={"system"}
import { sendEmailOTP, TokenScope } from '@dynamic-labs-sdk/client';

await sendEmailOTP({ email, purpose: TokenScope.Walletexport });
```

## 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.

> [tab] 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] 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 });
> ```

## 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.

> [tab] 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] 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();
> ```

> [!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.

## 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](https://docs.dynamic.xyz/docs/javascript/building-ui/mfa-enrollment-management.md)) 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.

## See also

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


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