Skip to content
Learnomy

Registration and Login Security

What protects the sign-up and sign-in routes, what does not, and how to add your own challenge.

What is already in place

Registration (POST /learnomy/v1/auth/register) ships with three defences and no configuration:

  • A honeypot field. Hidden from people, filled in by bots. A non-empty value is rejected.
  • Per-IP rate limiting. Both sign-up and sign-in are limited per address.
  • Email verification before sign-in. A new account cannot log in until the address is confirmed, which is what stops address squatting and spam-farm sign-ups rather than slowing them down.

What does not reach these forms

Security plugins cannot protect the registration form. Learnomy creates the account with wp_insert_user() and fires none of WordPress's register_* hooks, which is where a plugin's sign-up CAPTCHA attaches. If you switched on registration CAPTCHA in your security plugin, it is protecting wp-login.php?action=register and not this form.

We do not fire registration_errors to close that gap on your behalf, and the reason is worth knowing: this route receives a JSON body, so $_POST is empty. A CAPTCHA plugin reading its response token out of $_POST would find nothing and reject every sign-up on your site. Turning "unprotected" into "nobody can register" is the worse failure, so the seam below is explicit instead.

Sign-in is different. It authenticates through wp_signon(), so your security and two-factor plugins do apply to it. A site running two-factor authentication answers 409 with the code learnomy_login_requires_site_login, meaning the person must finish signing in on the site itself, where the second-factor screen can be shown.

The built-in anti-spam question

Learnomy Settings > Layouts > Anti-spam question adds a simple sum ("what is 6 + 5?") to both sign-up forms.

It is off by default. Switching it on for every site during an update would put a new required question in front of sign-up forms that were converting fine, which is not our call to make.

Why a sum rather than an image or a hosted widget:

  • It calls nobody. No third-party script, no API key, no visitor data leaving your site.
  • A screen reader can answer it. Distorted-image captchas cannot be read at all, and their audio fallbacks are their own accessibility problem.

What it is honestly for: the generic spam bot that posts at any form it finds. A bot written specifically for Learnomy can read the question and add two numbers - that is true of every question-based captcha. It works alongside the honeypot, rate limiting and email verification, not instead of them. If you are being targeted specifically, add a provider through the hooks below.

It needs no session or database row: the expected answer travels as a signature keyed to your site's salts, with a two-hour life so the question still works on a page your host serves from cache.

Adding your own challenge

Two hooks. Render whatever you like, then verify it on the server before the account exists.

// 1. Draw it on both sign-up forms.
add_action( 'learnomy_registration_challenge', function ( $context ) {
    // $context is 'registration_student' or 'registration_instructor',
    // so you can challenge one form and not the other.
    echo '<div class="cf-turnstile" data-sitekey="YOUR_KEY"></div>';
} );

// 2. Refuse the request when it fails. Runs BEFORE the user is created.
add_filter( 'learnomy_registration_challenge_verify', function ( $result, $request ) {
    $token = (string) $request->get_param( 'cf-turnstile-response' );

    if ( ! my_provider_says_this_is_a_human( $token ) ) {
        // The message is shown to the person signing up. Write it for them.
        return new WP_Error( 'challenge_failed', __( 'Please complete the anti-spam check.', 'my-plugin' ) );
    }

    return $result;
}, 10, 2 );

The form posts JSON built from FormData, so any named input you print is sent to the server without further work — read it with $request->get_param().

Verify on the server, always. A widget that only checks in the browser is decoration: the REST route is public and a bot will post to it directly. The filter is the protection; the action is only what the person sees.

Sign-up form hooks reference

Everything a plugin can add to, or take away from, the sign-up forms.

Hook Type What it does
learnomy_account_fields action Render extra fields. Receives ( $user_id, $context ) where context is registration_student, registration_instructor, account or account_instructor, so a field can target one form.
learnomy_account_fields_validate filter Refuse the submission. Receives ( $valid, $request, $context ) and runs before anything is written - return a WP_Error and the request stops with that message on the field.
learnomy_account_fields_save action Persist them. Receives ( $user_id, $request ) and fires immediately after the account row exists.
learnomy_registration_challenge action Render an anti-spam challenge. Receives the form context.
learnomy_registration_challenge_verify filter Verify it server-side before the account is created. Return true or a WP_Error.
learnomy_registration_captcha_enabled filter Force the built-in anti-spam question on or off, overriding the setting.
learnomy_pro_signup_picker_enabled filter Pro. Site-wide off switch for the "are you joining an organisation?" picker.
learnomy_pro_signup_picker_spaces filter Pro. The id => title map of spaces offered on the form.

Adding your own field, and making it required

Three hooks, in the order they run: draw it, refuse it, store it.

// 1. DRAW IT. $context is the surface asking, so a field can target one form:
//    registration_student | registration_instructor | account | account_instructor
add_action( 'learnomy_account_fields', function ( $user_id, $context = '' ) {
    if ( ! in_array( $context, array( 'registration_student', 'account' ), true ) ) {
        return;
    }
    $value = $user_id ? (string) get_user_meta( (int) $user_id, 'acme_employee_id', true ) : '';
    ?>
    <p class="lrn-field">
        <label class="lrn-field__label" for="acme-employee-id">
            Employee ID <span class="required" aria-hidden="true">*</span>
        </label>
        <input class="lrn-field__input" type="text"
               id="acme-employee-id" name="acme_employee_id"
               value="<?php echo esc_attr( $value ); ?>" required>
    </p>
    <?php
}, 10, 2 );

// 2. REQUIRE IT. Runs BEFORE the account is created, so this is what enforces it.
add_filter( 'learnomy_account_fields_validate', function ( $valid, $request, $context ) {
    if ( ! in_array( $context, array( 'registration_student', 'account' ), true ) || is_wp_error( $valid ) ) {
        return $valid;
    }
    if ( '' === trim( (string) $request->get_param( 'acme_employee_id' ) ) ) {
        return new WP_Error( 'acme_employee_id_required', __( 'Employee ID is required.', 'acme' ) );
    }
    return $valid;
}, 10, 3 );

// 3. STORE IT. Fires after the account row exists.
add_action( 'learnomy_account_fields_save', function ( $user_id, $request ) {
    $value = $request->get_param( 'acme_employee_id' );
    if ( null === $value ) {
        return;   // this form did not ask - do not wipe what is stored
    }
    update_user_meta( (int) $user_id, 'acme_employee_id', sanitize_text_field( (string) $value ) );
}, 10, 2 );

Why validate is separate from save. Save fires after wp_insert_user(). That is the right moment to store a value and far too late to refuse one, so a field could not be made required through save alone.

Scope the filter to the same surfaces you draw on. An instructor-only field that validates on every context will block a student signing up on a form that never showed it.

The required attribute is not enough on its own. The sign-up form is novalidate and its validator only knows its own field names, so the attribute is honoured as a convenience (it blocks the submit and shows the message without a round trip) while the filter is the authority - a value posted straight at the REST route is refused there.

Where the value goes is your choice. The hooks store nothing themselves; the example puts it in wp_usermeta.

No code needed for simple fields. Learnomy Settings → Layouts → Registration & profile fields covers text, email, phone, textarea, dropdown and checkbox with the same per-audience targeting and a Required checkbox, storing under learnomy_field_<key>.

Switching the space picker off site-wide

There is no setting for this, deliberately: a space appears on the sign-up form only when its owner ticks Offer this space on the sign-up form, which is off by default, so a second switch would mostly be a second place to look when the picker does not appear.

// Refuse the sign-up space picker on this site, whatever any space owner sets.
add_filter( 'learnomy_pro_signup_picker_enabled', '__return_false' );

It is checked where the offered list is built, not where the form is drawn. That matters: returning false also refuses a join request posted straight at the public register route, so it is a real off switch rather than a hidden control.

// Or narrow the list instead of removing it.
add_filter( 'learnomy_pro_signup_picker_spaces', function ( array $spaces ): array {
    return array_slice( $spaces, 0, 5, true );
} );

Related