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¶
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:
- AxeWP provider registration via
graphql_login_registered_provider_configsfilter — adds'otp' => OtpGraphQlProviderConfig::classto the registry array. requestOtpmutation viagraphql_register_typesaction — 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.username → email, credentials.password → code |
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 |