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
- Custom Account Fields - the same render/verify pattern for collecting extra information.