Skip to content

Registration and Login Security

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

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.

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.

LMS Settings > Registration Fields > 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.

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.

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_student_registration_enabled filter Force student self-registration on or off, overriding the Allow students to sign up setting. Return false and the register page 404s, the sign-up API rejects students, and every “Sign up” button retargets to login.
learnomy_student_registration_url filter Where “Sign up” buttons point when student registration is off. Receives ( $url, $redirect ) and defaults to the login page - return your own form’s URL (e.g. a Gravity Forms page) to send visitors there instead.
learnomy_instructor_registration_enabled filter Force instructor registration on or off (also off automatically in solo mode). Return false and the become-instructor / register-instructor pages 404 and the API rejects role=instructor.
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.

Turning self-registration off, or handing it to your own form

Section titled “Turning self-registration off, or handing it to your own form”

Most owners use the Settings → General → Academy Mode toggles, but the same switches are filters, so you can force them per environment or wire them to your own logic:

// Close student self-registration entirely (members-only academy).
add_filter( 'learnomy_student_registration_enabled', '__return_false' );
// ...or keep it open, but send every "Sign up" button to your own form
// (e.g. a Gravity Forms page) instead of the built-in register page.
add_filter( 'learnomy_student_registration_url', function ( $url, $redirect ) {
return add_query_arg( 'redirect_to', rawurlencode( $redirect ), home_url( '/join/' ) );
}, 10, 2 );

When registration is off, the register page returns a 404, the REST auth/register route rejects student sign-ups, and every built-in “Sign up to enroll” button resolves through learnomy_student_registration_url() - so there is never a link left pointing at the disabled page. If your own form still needs to create the account, post to the same REST route (POST /wp-json/learnomy/v1/auth/register) or call wp_insert_user() with the lrn_student role from your form’s submit handler.

Adding your own field, and making it required

Section titled “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. LMS Settings → Registration Fields → 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>.

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 );
} );