Skip to content

Instantly share code, notes, and snippets.

@vfontjr
Created October 21, 2025 10:46
Show Gist options
  • Select an option

  • Save vfontjr/54be100be745c6d3aeade11f005f5e75 to your computer and use it in GitHub Desktop.

Select an option

Save vfontjr/54be100be745c6d3aeade11f005f5e75 to your computer and use it in GitHub Desktop.
<?php
/**
* Email OTP for Formidable Forms
* - Generates and emails a 6-digit OTP tied to the submitted email.
* - Stores OTP in a transient for 10 minutes.
* - Validates on submit; blocks entry if code is wrong/expired.
*
* Replace FORM_ID, EMAIL_FIELD_ID, CODE_FIELD_ID with your actual IDs.
*/
const FF_OTP_FORM_ID = 11; // <-- your form ID
const FF_OTP_EMAIL_FIELD = 25; // <-- your email field ID
const FF_OTP_CODE_FIELD = 26; // <-- your code field ID
const FF_OTP_LENGTH = 6;
const FF_OTP_TTL_SECONDS = 10 * 60; // 10 minutes
const FF_OTP_RATE_SECONDS = 60; // min seconds between sends to same email
add_action('wp_enqueue_scripts', function () {
// Localize admin-ajax endpoint + nonce for the frontend script below.
wp_register_script('ff-otp-inline', '', [], null, true);
wp_enqueue_script('ff-otp-inline');
wp_add_inline_script('ff-otp-inline', sprintf(
'window.ffOTP = { ajaxUrl: %s, nonce: %s, rate: %d };',
json_encode(admin_url('admin-ajax.php')),
json_encode(wp_create_nonce('ff_otp_nonce')),
FF_OTP_RATE_SECONDS
));
});
/**
* AJAX: send OTP to provided email (no form submit required).
*/
add_action('wp_ajax_ff_send_otp', 'ff_send_otp');
add_action('wp_ajax_nopriv_ff_send_otp', 'ff_send_otp');
function ff_send_otp() {
if (!check_ajax_referer('ff_otp_nonce', 'nonce', false)) {
wp_send_json_error(['message' => 'Security check failed.'], 403);
}
$email = isset($_POST['email']) ? sanitize_email(wp_unslash($_POST['email'])) : '';
if (!$email || !is_email($email)) {
wp_send_json_error(['message' => 'Please enter a valid email address.'], 400);
}
$key = ff_otp_key($email);
$existing = get_transient($key);
// Simple rate-limit
if (!empty($existing['last_sent']) && (time() - (int)$existing['last_sent']) < FF_OTP_RATE_SECONDS) {
$wait = FF_OTP_RATE_SECONDS - (time() - (int)$existing['last_sent']);
wp_send_json_error(['message' => "Please wait {$wait}s before requesting a new code."], 429);
}
$code = ff_rand_code(FF_OTP_LENGTH);
$payload = [
'code' => $code,
'expires' => time() + FF_OTP_TTL_SECONDS,
'last_sent' => time(),
'attempts' => 0,
];
set_transient($key, $payload, FF_OTP_TTL_SECONDS);
$sent = wp_mail(
$email,
'Your Verification Code',
"Your verification code is: {$code}\n\nThis code expires in 10 minutes.",
['Content-Type: text/plain; charset=UTF-8']
);
if (!$sent) {
wp_send_json_error(['message' => 'We could not send the email right now. Please try again later.'], 500);
}
wp_send_json_success(['message' => 'Verification code sent. Please check your inbox.']);
}
function ff_otp_key(string $email): string {
return 'ff_otp_' . md5(strtolower(trim($email)));
}
function ff_rand_code(int $digits = 6): string {
$min = (int) pow(10, $digits - 1);
$max = (int) pow(10, $digits) - 1;
return (string) wp_rand($min, $max);
}
/**
* Validate the OTP on submit.
* Use Formidable's per-field validation filter so errors show inline.
*/
add_filter('frm_validate_field_entry', function ($errors, $posted_field, $posted_value) {
// Only enforce on our Code field, within our form
if ((int) $posted_field->id !== FF_OTP_CODE_FIELD) {
return $errors;
}
$form_id = (int) $posted_field->form_id;
if ($form_id !== FF_OTP_FORM_ID) {
return $errors;
}
$email_field_key = 'item_meta[' . FF_OTP_EMAIL_FIELD . ']';
$email = isset($_POST['item_meta'][FF_OTP_EMAIL_FIELD])
? sanitize_email(wp_unslash($_POST['item_meta'][FF_OTP_EMAIL_FIELD]))
: '';
$code = is_string($posted_value) ? trim($posted_value) : '';
if (!$email || !is_email($email)) {
$errors['field' . FF_OTP_CODE_FIELD] = 'Please enter a valid email first and request a code.';
return $errors;
}
if ($code === '') {
$errors['field' . FF_OTP_CODE_FIELD] = 'Enter the 6-digit verification code.';
return $errors;
}
$payload = get_transient(ff_otp_key($email));
if (!$payload || empty($payload['code']) || empty($payload['expires'])) {
$errors['field' . FF_OTP_CODE_FIELD] = 'No active code found. Please click “Send Verification Code”.';
return $errors;
}
// Expired?
if (time() > (int) $payload['expires']) {
delete_transient(ff_otp_key($email));
$errors['field' . FF_OTP_CODE_FIELD] = 'That code expired. Please request a new one.';
return $errors;
}
// Match?
if (!hash_equals((string) $payload['code'], (string) $code)) {
$payload['attempts'] = (int) ($payload['attempts'] ?? 0) + 1;
// Optional: lockout after N attempts by deleting transient or shortening TTL
set_transient(ff_otp_key($email), $payload, (int) ($payload['expires'] - time()));
$errors['field' . FF_OTP_CODE_FIELD] = 'Incorrect code. Please try again.';
return $errors;
}
// Success: clear the transient so the code can’t be reused
delete_transient(ff_otp_key($email));
return $errors;
}, 10, 3);
/**
* (Optional) Hard block at the entry-level as a failsafe.
* If you prefer a single, form-level error instead of field-level, you can move validation here.
*/
add_filter('frm_entries_before_create', function ($errors, $form) {
if ((int) $form->id !== FF_OTP_FORM_ID) {
return $errors;
}
// If the per-field validator already added an error, we’re done.
if (!empty($errors)) {
return $errors;
}
return $errors;
}, 30, 2);
<script>
document.addEventListener('click', async function (e) {
if (e.target && e.target.id === 'ff-send-otp') {
const status = document.getElementById('ff-otp-status');
const btn = e.target;
// Update with your field selector; Formidable inputs commonly use #field_FIELDID
const emailInput = document.querySelector('#field_<?= FF_OTP_EMAIL_FIELD ?>');
const email = emailInput ? emailInput.value.trim() : '';
if (!email) {
status.textContent = 'Please enter your email address first.';
return;
}
btn.disabled = true;
status.textContent = 'Sending...';
try {
const res = await fetch(window.ffOTP.ajaxUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({
action: 'ff_send_otp',
email: email,
nonce: window.ffOTP.nonce
})
});
const data = await res.json();
if (data.success) {
status.textContent = data.data.message;
// Simple cooldown timer
let remaining = window.ffOTP.rate || 60;
const original = btn.textContent;
const timer = setInterval(() => {
btn.textContent = `Resend (${remaining}s)`;
if (--remaining <= 0) {
clearInterval(timer);
btn.textContent = original;
btn.disabled = false;
}
}, 1000);
} else {
status.textContent = data?.data?.message || 'Could not send the code.';
btn.disabled = false;
}
} catch (err) {
status.textContent = 'Network error. Please try again.';
btn.disabled = false;
}
}
});
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment