Skip to content

GraphQL Flow

The GraphQL layer targets headless setups (e.g. Nuxt) and requires both WPGraphQL and wp-graphql-headless-login (AxeWP). It is fully optional — the plugin works as a standalone WP login replacement without it.


Requirements

Plugin Minimum version
WPGraphQL ≥ 1.14
wp-graphql-headless-login (AxeWP) any

The GraphQL layer is not loaded unless \WPGraphQL\Login\Auth\ProviderConfig\ProviderConfig exists at runtime. See architecture.md.


Two-step login flow

Step 1 — Request OTP              Step 2 — Submit OTP
──────────────────────────        ─────────────────────────────
mutation RequestOtp               mutation Login
  input: { email }                  input: {
                                      provider: OTP
  → OTP generated                     credentials: {
  → Email sent                           username: <email>
  → Returns: codeLength                  password: <code>
                                      }
                                    }
                                  → JWT tokens returned

Step 1 — requestOtp mutation

Purpose

Look up the user by email, generate an OTP, and send it. Returns codeLength so the frontend can render the correct number of input fields.

Request

mutation RequestOtp($email: String!) {
  requestOtp(input: { email: $email }) {
    success
    codeLength
  }
}

Response fields

Field Type Description
success Boolean! Always true. Do not use this to infer whether the email exists.
codeLength Int! Number of characters in the OTP code. Use this to render the correct number of input fields on the frontend.

Behaviour

Scenario success Error thrown
Valid email, mail sent true no
Email not found true no
Invalid/empty email true no
OTP generation failed UserError
Mail delivery failed UserError

Only genuine technical failures surface as GraphQL errors. User enumeration is prevented by always returning success: true for unknown emails.


Step 2 — login mutation (AxeWP)

Provided by wp-graphql-headless-login. The OTP plugin registers itself as the OTP provider via the graphql_login_registered_provider_configs filter.

Credential mapping

AxeWP exposes a shared PasswordProviderResponseInput for all providers:

AxeWP field WP OTP meaning
username The user's email address
password The OTP code

Request

mutation Login($email: String!, $code: String!) {
  login(
    input: {
      provider: OTP
      credentials: {
        username: $email
        password: $code
      }
    }
  ) {
    authToken
    refreshToken
    user {
      id
      name
      email
    }
  }
}

Variables

{
  "email": "user@example.com",
  "code": "483920"
}

Response

On success AxeWP returns standard JWT fields. On failure it returns a GraphQL error — the OTP plugin calls graphql_debug() with the internal reason before returning false, which AxeWP translates into a generic authentication error.


Nuxt example

A complete two-step login composable using @nuxtjs/apollo:

// composables/useOtpLogin.ts

const REQUEST_OTP = gql`
  mutation RequestOtp($email: String!) {
    requestOtp(input: { email: $email }) {
      success
      codeLength
    }
  }
`

const LOGIN = gql`
  mutation Login($email: String!, $code: String!) {
    login(
      input: {
        provider: OTP
        credentials: { username: $email, password: $code }
      }
    ) {
      authToken
      refreshToken
    }
  }
`

export function useOtpLogin() {
  const { mutate: requestOtp } = useMutation(REQUEST_OTP)
  const { mutate: login }      = useMutation(LOGIN)

  // Step 1 — request the code.
  // Always advances to step 2 regardless of whether the email exists.
  async function sendCode(email: string) {
    const { data } = await requestOtp({ email })
    return data?.requestOtp ?? null
  }

  // Step 2 — submit the code.
  // Returns { authToken, refreshToken } on success.
  async function verifyCode(email: string, code: string) {
    const { data } = await login({ email, code })
    return data?.login ?? null
  }

  return { sendCode, verifyCode }
}

Usage in a page component

<script setup lang="ts">
const { sendCode, verifyCode } = useOtpLogin()

const email      = ref('')
const code       = ref('')
const step       = ref<'email' | 'code'>('email')
const codeLength = ref(6)

async function onSubmitEmail() {
  const result = await sendCode(email.value)
  // Always advance to step 2 — avoids revealing whether the email exists.
  codeLength.value = result?.codeLength ?? 6
  step.value = 'code'
}

async function onSubmitCode() {
  const tokens = await verifyCode(email.value, code.value)
  if (tokens?.authToken) {
    // Store tokens and redirect.
  }
}
</script>

<template>
  <form v-if="step === 'email'" @submit.prevent="onSubmitEmail">
    <input v-model="email" type="email" placeholder="your@email.com" />
    <button type="submit">Send code</button>
  </form>

  <form v-else @submit.prevent="onSubmitCode">
    <!-- Render exactly codeLength individual character inputs, or one input -->
    <input
      v-model="code"
      type="text"
      inputmode="numeric"
      :maxlength="codeLength"
      autocomplete="one-time-code"
      autofocus
    />
    <button type="submit">Log in</button>
  </form>
</template>

Internal registration

GraphQlProvider::register_hooks() wires up two things:

  1. AxeWP provider registration via graphql_login_registered_provider_configs filter — adds 'otp' => OtpGraphQlProviderConfig::class to the registry array.
  2. requestOtp mutation via graphql_register_types action — registers the custom mutation with WPGraphQL.

OtpGraphQlProviderConfig extends AxeWP's abstract ProviderConfig and implements:

Method Behaviour
get_type() Returns 'otp'
get_name() Returns 'One-Time Password'
get_slug() Returns 'otp'
is_enabled() Always true
prepare_mutation_input() Maps credentials.usernameemail, credentials.passwordcode
authenticate_and_get_user_data() Calls OtpStore::validate_for_email(), returns WP_User or false
get_user_from_data() Pass-through for WP_User/WP_Error, rejects anything else