Need to find something?

Hire Me
Hire Me

OTP Without Database – Simple SMS Authentication with Javascript & PHP

OTP-Without-Database-Simple-SMS-Authentication-with-Javascript-PHP

OTP stands for ‘One Time Password’, usually seen in most apps or websites for Two Factor Authentication. It mostly involves database where you temporarily store the data to verify. However, in some cases, it is better and very economical if we can avoid the database and that’s what we will be doing in this tutorial.

Instead of storing the OTP in a database or server-side session, we will use HMAC-based verification – the server generates a cryptographic hash from the phone number, OTP code, and an expiry timestamp, then sends this hash back to the client. When the user submits the OTP, the client returns the hash along with the code, and the server re-computes the hash to verify. No server-side state is needed during this exchange. A short-lived, single-use token is issued only after successful verification to authorize the final submission.

How it Works

I saw a post by Anam Ahmed about this topic way back in 2019 and found it really interesting. It was based on Javascript. At that time, I did not get a chance to implement this in one of my projects, until very recently. My project is based on PHP (WordPress). You can get the detailed explanation in the original post here.

Unlike a database-tied verification, this method verifies OTP using cryptography. Here’s the full flow: when a user requests an OTP, the server generates a code and creates an HMAC hash from the phone number, the OTP, and an expiry timestamp. This hash is sent back to the client (stored in a hidden form field) – not in a database or session. When the user submits the OTP, the client sends both the code and the hash back to the server, which re-computes the hash to verify. On success, the server issues a single-use token that gates the final form submission.

Our Project

To make things easier to understand, I am going to explain the process with a sample project built with WordPress so we can test the OTP without database. This can be replicated in other CMS running on PHP or Vanilla PHP applications but will require tweaking.

We are going to build a form that will collect user’s anonymous feedback while keeping the feedbacks genuine as we will be verifying the submissions with OTP that the user will receive via SMS. You can easily make it into an email-based verification as well.

The Setup

First of all, we are going to set up a fresh, up to date WordPress installation. To create things easier to understand, let’s create a folder inside your theme folder called “otp-wo-database“.

Next, we will create a custom post type for the reviews. Each submission of the review form will create a new post in our custom post type. Let’s create a file “otp-post-type.php” inside the folder we just created earlier and put the following code on it.

<?php 
function register_review_post_type() {

  $labels = [
    "name" => __( "Reviews", "textdomain" ),
    "singular_name" => __( "Review", "textdomain" ),
  ];

  $args = [
    "label" => __( "Reviews", "textdomain" ),
    "labels" => $labels,
    "description" => "",
    "public" => true,
    "publicly_queryable" => true,
    "show_ui" => true,
    "show_in_rest" => true,
    "rest_base" => "",
    "rest_controller_class" => "WP_REST_Posts_Controller",
    "has_archive" => false,
    "show_in_menu" => true,
    "show_in_nav_menus" => true,
    "delete_with_user" => false,
    "exclude_from_search" => false,
    "capability_type" => "post",
    "map_meta_cap" => true,
    "hierarchical" => false,
    "rewrite" => [ "slug" => "review", "with_front" => true ],
    "query_var" => true,
    "supports" => [ "title", "editor", "author", "custom-fields" ],
  ];

  register_post_type( "review", $args );
}

add_action( 'init', 'register_review_post_type' );
Code language: PHP (php)

The code is yet to be recognized by WordPress, to make that, we add the following code in our theme’s functions.php file. We will be doing this for other files we will be adding soon as well.

require_once __DIR__ . '/otp-wo-database/otp-post-type.php';Code language: PHP (php)

The Shortcode

Next, we are going to create a shortcode that will show the review submission form and submitted reviews. At the bottom, we are also adding a function that will load the javascript file and pass both the WordPress Ajax URL and a CSRF nonce to it via wp_localize_script. The form also includes hidden fields to store the OTP hash and submission token on the client side. This code will be on a new file named “otp-shortcode.php” inside our “otp-wo-database” folder.

<?php

if ( ! defined( 'ABSPATH' ) ) {
  exit( 'Direct script access denied.' );
}

function otp_reviews_shortcode( $atts ) {
  otp_reviews_scripts();
  ob_start(); ?>
  <h3>Submit Review Anonymously</h3>
  <form action="" id="otp-review-form">
    <div id="review-submission">
      <input type="text" id="review-title" name="review-title" value="" placeholder="Title" required />
      <textarea name="review-comment" id="review-comment" cols="30" rows="2" placeholder="Comment" required></textarea>
      <div class="rating">
        <input id="star5" name="star" type="radio" value="5" class="radio-btn hide" />
        <label for="star5">&#9734;</label>
        <input id="star4" name="star" type="radio" value="4" class="radio-btn hide" />
        <label for="star4">&#9734;</label>
        <input id="star3" name="star" type="radio" value="3" class="radio-btn hide" />
        <label for="star3">&#9734;</label>
        <input id="star2" name="star" type="radio" value="2" class="radio-btn hide" />
        <label for="star2">&#9734;</label>
        <input id="star1" name="star" type="radio" value="1" class="radio-btn hide" />
        <label for="star1">&#9734;</label>
        <div class="clear"></div>
      </div>
      <a href="" id="goto-otp">Proceed</a>
    </div>
    <div id="otp-generate">
      <input type="tel" id="otp-phone" name="otp-phone" value="" placeholder="Phone (with country code, e.g. +1...)" required />
      <a href="" id="send-otp">Send OTP</a>
    </div>
    <div id="otp-verify">
      <input type="text" id="otp-code" name="otp-code" value="" placeholder="OTP Code" maxlength="6" pattern="\d{6}" required />
      <a href="" id="verify-otp">Verify</a>
    </div>
    <input type="hidden" id="otp-hash" name="otp-hash" value="" />
    <input type="hidden" id="otp-submit-token" name="otp-submit-token" value="" />
    <input type="hidden" id="otp-phone-value" name="otp-phone-value" value="" />
    <div id="finish">
      <div class="message"></div>
    </div>
  </form>

  <div id="otp-reviews">
    <?php
    $paged = get_query_var( 'paged' ) ? get_query_var( 'paged' ) : 1;
    $args = array(
      'post_type'      => 'review',
      'posts_per_page' => 10,
      'paged'          => $paged,
      'order'          => 'DESC',
    );
    $reviews_query = new WP_Query( $args );

    if ( $reviews_query->have_posts() ) { ?>
      <h3 class="submitted-reviews">Submitted Reviews</h3>
      <?php while ( $reviews_query->have_posts() ) {
        $reviews_query->the_post(); ?>
        <div class="otp-review">
          <h3><?php the_title(); ?></h3>
          <p class="rating"><?php echo esc_html( get_post_meta( get_the_ID(), 'review_rating', true ) ); ?> &#9734;</p>
          <p class="review"><?php the_content(); ?></p>
        </div>
      <?php }
      wp_reset_postdata();

      // Simple pagination links
      $total_pages = $reviews_query->max_num_pages;
      if ( $total_pages > 1 ) {
        echo '<div class="review-pagination">';
        for ( $i = 1; $i <= $total_pages; $i++ ) {
          $active = ( $paged === $i ) ? ' current' : '';
          printf( '<a href="%s" class="page-num%s">%d</a> ', get_pagenum_link( $i ), $active, $i );
        }
        echo '</div>';
      }
    } ?>
  </div>
  <?php
  return ob_get_clean();
}

add_shortcode( 'otp_reviews', 'otp_reviews_shortcode' );

function otp_reviews_scripts() {
  wp_enqueue_script( 'otp-review', get_stylesheet_directory_uri() . '/otp-wo-database/otp-review.js', array( 'jquery' ), '2.0.0', true );
  wp_localize_script( 'otp-review', 'otp_ajax', array(
    'url'   => admin_url( 'admin-ajax.php' ),
    'nonce' => wp_create_nonce( 'otp_action' ),
  ) );
}

Code language: PHP (php)

And just like earlier, let’s add the following in functions.php file

require_once __DIR__ . '/otp-wo-database/otp-shortcode.php';Code language: PHP (php)

OTP Functions

Now we are going to add a new file inside our “otp-wo-database” folder called “otp-functions.php” and put the following code on it.

<?php

if ( ! defined( 'ABSPATH' ) ) {
  exit( 'Direct script access denied.' );
}

if ( ! defined( 'OTP_SECRET_KEY' ) ) {
  define( 'OTP_SECRET_KEY', 'change-this-to-a-long-random-string-in-production' );
}

function create_hash( $phone, $otp, $ttl ) {
  $expires = time() + $ttl * 60;
  $data = $phone . $otp . $expires;
  $hash = hash_hmac( 'sha256', $data, OTP_SECRET_KEY );
  return $hash . '.' . $expires;
}

function verify_otp( $phone, $otp, $hash ) {
  if ( strpos( $hash, '.' ) === false ) {
    return false;
  }

  $hashdata = explode( '.', $hash );

  if ( count( $hashdata ) !== 2 ) {
    return false;
  }

  if ( $hashdata[1] < time() ) {
    return false;
  }

  $data = $phone . $otp . $hashdata[1];
  $newHash = hash_hmac( 'sha256', $data, OTP_SECRET_KEY );

  if ( hash_equals( $newHash, $hashdata[0] ) ) {
    return true;
  }

  return false;
}

function generate_otp( $length = 6 ) {
  $chars = '0123456789';
  $charLength = strlen( $chars );
  $otpString = '';
  for ( $i = 0; $i < $length; $i++ ) {
    $otpString .= $chars[ random_int( 0, $charLength - 1 ) ];
  }
  return $otpString;
}
Code language: PHP (php)

We have 3 functions on this file that we will use later for creating a hash, verifying OTP, and generating OTP code. A few important details about these functions:

  • The secret key is loaded from a constant OTP_SECRET_KEY (define it in wp-config.php in production — never hardcode secrets in source files).
  • OTP codes are generated using random_int() instead of rand() for cryptographic security.
  • Hash comparison uses hash_equals() instead of == to prevent timing attacks.
  • The expiry calculation uses PHP’s time() (seconds), not JavaScript-style milliseconds.

Then, making sure our new file is loaded.

require_once __DIR__ . '/otp-wo-database/otp-functions.php';Code language: PHP (php)

Ajax Requests

Notice that we have enqueued a javascript file in our shortcode with “otp_reviews_scripts” function at the end. Now, we are going to add that js file.

Create “otp-review.js” file inside the “otp-wo-database” folder and put the following code.

;(function ($) {
  $(document).ready(function () {

    function showError(message, beforeSelector) {
      $('.error').remove();
      $('<span class="error">' + message + '</span>').insertBefore(beforeSelector);
    }

    function otpAjax(action, data, onSuccess, onError) {
      data.action = action;
      data.nonce  = otp_ajax.nonce; 

      $.ajax({
        url: otp_ajax.url, 
        type: 'POST',
        data: data,
        success: function (response) {
          if (response.success) {
            onSuccess(response.data);
          } else {
            if (onError) {
              onError(response.data && response.data.message ? response.data.message : 'Request failed.');
            }
          }
        },
        error: function (xhr) {
          var msg = 'Network error. Please try again.';
          try {
            var resp = JSON.parse(xhr.responseText);
            if (resp.data && resp.data.message) msg = resp.data.message;
          } catch (e) {}
          if (onError) onError(msg);
        }
      });
    }

    // --- Step 1: Validate review fields and proceed ---
    $('#goto-otp').click(function (e) {
      e.preventDefault();
      if ($('input:radio[name=star]').is(':checked') && $('#review-title').val().trim() && $('#review-comment').val().trim()) {
        $('#review-submission').hide();
        $('#otp-generate').show();
      } else {
        showError('Please fill all fields and select a rating.', '#review-title');
      }
    });

    // --- Step 2: Request OTP ---
    $('#send-otp').click(function (e) {
      e.preventDefault();
      var phone = $('#otp-phone').val().trim();

      if (!phone || phone.length < 7) {
        showError('Please enter a valid phone number with country code.', '#otp-phone');
        return;
      }

      otpAjax('request_otp', { phone: phone },
        function (data) {
          $('#otp-hash').val(data.hash);
          $('#otp-phone-value').val(phone);
          $('#otp-generate').hide();
          $('#otp-verify').show();
        },
        function (message) {
          showError(message, '#otp-phone');
        }
      );
    });

    // --- Step 3: Verify OTP ---
    $('#verify-otp').click(function (e) {
      e.preventDefault();
      var otp  = $('#otp-code').val().trim();
      var hash = $('#otp-hash').val();

      if (!otp || otp.length !== 6 || !/^\d{6}$/.test(otp)) {
        showError('Please enter a valid 6-digit OTP code.', '#otp-code');
        return;
      }

      otpAjax('verify_otp', {
        otp:   otp,
        hash:  hash,
        phone: $('#otp-phone-value').val()
      },
        function (data) {
          // Store submit token for the final step
          $('#otp-submit-token').val(data.submit_token);
          $('#otp-verify').hide();
          $('#finish').show();
          // Submit the review immediately after verification
          processReview();
        },
        function (message) {
          showError(message, '#otp-code');
        }
      );
    });

    // --- Step 4: Submit the review ---
    function processReview() {
      var title = $('#review-title').val().trim();
      var comment = $('#review-comment').val().trim();
      var rating = $('input[name="star"]:checked').val();

      otpAjax('submit_review', {
        title:        title,
        comment:      comment,
        rating:       rating,
        submit_token: $('#otp-submit-token').val() 
      },
        function (data) {
          $('#finish .message').text(data.message);
          setTimeout(function () { location.reload(); }, 3000);
        },
        function (message) {
          $('#finish .message').text(message || 'Submission failed.');
        }
      );
    }
  });
})(jQuery);

Code language: JavaScript (javascript)

We have 3 Ajax actions in our javascript code, all sent via POST with a CSRF nonce for security. A reusable otpAjax() helper handles the request boilerplate and error handling:

  • Request OTP: sends the phone number, receives the HMAC hash back and stores it in a hidden field.
  • Verify OTP: sends the OTP code, hash, and phone number. On success, receives a single-use submit_token from the server.
  • Submit Review: sends the review data along with the submit_token to prove the phone was verified. Without a valid token, the server rejects the submission.

The last two actions could have been combined but I wanted to make it as simple as possible for everyone.

Callback Functions

We have our Ajax requests ready, now we will create the callback functions for them. As mentioned earlier, we have 3 of them. All callbacks include nonce verification via check_ajax_referer(), read data from $_POST (not $_GET), and return structured JSON responses using wp_send_json_success() and wp_send_json_error().

First we are going to add “otp-cb-request-otp.php” file inside our “otp-wo-database” folder and put the following code. This callback validates the nonce, rate-limits OTP requests to 3 per phone number per 15 minutes (using WordPress transients), wraps the Twilio SMS call in a try/catch block, and returns the HMAC hash to the client instead of storing it server-side.

<?php

if ( ! defined( 'ABSPATH' ) ) {
  exit( 'Direct script access denied.' );
}

// Load Twilio
require_once __DIR__ . '/Twilio/autoload.php';
use Twilio\Rest\Client;

add_action( 'wp_ajax_request_otp',        'request_otp_callback' );
add_action( 'wp_ajax_nopriv_request_otp', 'request_otp_callback' );

function request_otp_callback() {
  check_ajax_referer( 'otp_action', 'nonce' );

  if ( ! isset( $_POST['phone'] ) || empty( $_POST['phone'] ) ) {
    wp_send_json_error( array( 'message' => 'Phone number is required.' ), 400 );
  }

  $phone = sanitize_text_field( $_POST['phone'] );

  $rate_key = 'otp_rate_' . md5( $phone );
  $attempts = (int) get_transient( $rate_key );
  if ( $attempts >= 3 ) {
    wp_send_json_error( array( 'message' => 'Too many OTP requests. Try again later.' ), 429 );
  }
  set_transient( $rate_key, $attempts + 1, 15 * MINUTE_IN_SECONDS );

  $ttl  = 2;
  $otp  = generate_otp( 6 );
  $hash = create_hash( $phone, $otp, $ttl );

  $sid   = defined( 'TWILIO_SID' )   ? TWILIO_SID   : '';
  $token = defined( 'TWILIO_TOKEN' ) ? TWILIO_TOKEN : '';
  $from  = defined( 'TWILIO_FROM' )  ? TWILIO_FROM  : '';

  if ( empty( $sid ) || empty( $token ) || empty( $from ) ) {
    wp_send_json_error( array( 'message' => 'SMS service not configured.' ), 500 );
  }

  try {
    $client = new Client( $sid, $token );
    $client->messages->create(
      $phone,
      array(
        'from' => $from,
        'body' => sprintf( 'Your OTP is %s. It will expire in %d mins.', $otp, $ttl ),
      )
    );
  } catch ( \Exception $e ) {
    wp_send_json_error( array( 'message' => 'Failed to send SMS. Please try again.' ), 500 );
  }

  // Return hash to the CLIENT (no server-side storage)
  wp_send_json_success( array(
    'message' => 'OTP Sent',
    'hash'    => $hash, // Client stores this in a hidden field
  ) );
}
Code language: PHP (php)

As mentioned in the code, I am using Twilio for this project. You will require to download the PHP SDK provided by them here and unzip to our “otp-wo-database” folder. Make sure to define your Twilio credentials in wp-config.php:

define( 'TWILIO_SID',   'your_account_sid' );
define( 'TWILIO_TOKEN', 'your_auth_token' );
define( 'TWILIO_FROM',  '+11234567890' );Code language: JavaScript (javascript)

You can signup for free (like I did for this tutorial) and try out their service. If you sign up with my referral link, you and I both will get a $10 credit. You can get more information about the PHP SDK from Twilio here.

The next file will contain the OTP verifying callback function. Let’s create “otp-cb-verify.php” file inside, of course “otp-wo-database” folder. This callback rate-limits verification attempts to 5 per hash, and on success generates a single-use submit_token (via wp_generate_password()) stored as a 5-minute transient. This token is what the final submission step requires — without it, no review can be submitted.

<?php

if ( ! defined( 'ABSPATH' ) ) {
  exit( 'Direct script access denied.' );
}

add_action( 'wp_ajax_verify_otp',        'verify_otp_callback' );
add_action( 'wp_ajax_nopriv_verify_otp', 'verify_otp_callback' );

function verify_otp_callback() {
  check_ajax_referer( 'otp_action', 'nonce' );

  if ( ! isset( $_POST['otp'] ) || empty( $_POST['otp'] ) ) {
    wp_send_json_error( array( 'message' => 'OTP code is required.' ), 400 );
  }

  if ( ! isset( $_POST['hash'] ) || empty( $_POST['hash'] ) ) {
    wp_send_json_error( array( 'message' => 'Missing verification data.' ), 400 );
  }

  $verify_key = 'otp_verify_' . md5( $_POST['hash'] );
  $verify_attempts = (int) get_transient( $verify_key );
  if ( $verify_attempts >= 5 ) {
    wp_send_json_error( array( 'message' => 'Too many attempts. Please request a new OTP.' ), 429 );
  }
  set_transient( $verify_key, $verify_attempts + 1, 10 * MINUTE_IN_SECONDS );

  $otp   = sanitize_text_field( $_POST['otp'] );
  $hash  = sanitize_text_field( $_POST['hash'] );
  $phone = sanitize_text_field( $_POST['phone'] );

  $verify = verify_otp( $phone, $otp, $hash );

  if ( $verify ) {
    // Generate a one-time token that the submit endpoint will accept
    $submit_token = wp_generate_password( 32, false );
    set_transient( 'otp_verified_' . $submit_token, $phone, 5 * MINUTE_IN_SECONDS );

    wp_send_json_success( array(
      'message'      => 'OTP is valid',
      'submit_token' => $submit_token, // Client sends this with the review
    ) );
  } else {
    wp_send_json_error( array( 'message' => 'Invalid or expired OTP.' ), 401 );
  }
}
Code language: PHP (php)

Lastly, we will add another file for submitting the callback function that submits the review after the OTP verification is done. Let’s create “otp-cb-submit.php” file inside “otp-wo-database” folder and put the following code. This endpoint requires a valid submit_token from the verify step – it checks the transient, consumes it immediately (so it can’t be reused), and validates that the rating is between 1 and 5.

<?php

if ( ! defined( 'ABSPATH' ) ) {
  exit( 'Direct script access denied.' );
}

add_action( 'wp_ajax_submit_review',        'submit_review_callback' );
add_action( 'wp_ajax_nopriv_submit_review', 'submit_review_callback' );

function submit_review_callback() {
  check_ajax_referer( 'otp_action', 'nonce' );

  $title   = isset( $_POST['title'] )   ? sanitize_text_field( $_POST['title'] )   : '';
  $comment = isset( $_POST['comment'] ) ? sanitize_textarea_field( $_POST['comment'] ) : '';
  $rating  = isset( $_POST['rating'] )  ? intval( $_POST['rating'] ) : 0;
  $token   = isset( $_POST['submit_token'] ) ? sanitize_text_field( $_POST['submit_token'] ) : '';

  if ( empty( $token ) ) {
    wp_send_json_error( array( 'message' => 'Phone verification required.' ), 403 );
  }

  $verified_phone = get_transient( 'otp_verified_' . $token );
  if ( false === $verified_phone ) {
    wp_send_json_error( array( 'message' => 'Verification expired. Please try again.' ), 403 );
  }

  // Consume the token so it can't be reused
  delete_transient( 'otp_verified_' . $token );

  if ( empty( $title ) || empty( $comment ) ) {
    wp_send_json_error( array( 'message' => 'Title and comment are required.' ), 400 );
  }

  if ( $rating < 1 || $rating > 5 ) {
    wp_send_json_error( array( 'message' => 'Invalid rating.' ), 400 );
  }

  $review_args = array(
    'post_title'   => $title,
    'post_content' => $comment,
    'post_status'  => 'publish',
    'post_type'    => 'review',
  );

  $review_id = wp_insert_post( $review_args );

  if ( is_wp_error( $review_id ) ) {
    wp_send_json_error( array( 'message' => 'Submission Failed!' ), 500 );
  }

  update_post_meta( $review_id, 'review_rating', $rating );
  wp_send_json_success( array( 'message' => 'Submission Successful!' ) );
}
Code language: PHP (php)

Let’s not forget to require our callback functions and add the following to our theme’s functions.php file. Note the commented lines – define the Twilio credentials and the OTP secret key in wp-config.php for production use, never in theme files.

// Define these in wp-config.php in production, not here:
// define( 'TWILIO_SID',   'your_sid' );
// define( 'TWILIO_TOKEN', 'your_token' );
// define( 'TWILIO_FROM',  '+11234567890' );
// define( 'OTP_SECRET_KEY', 'a-very-long-random-secret-string' );

require_once __DIR__ . '/otp-wo-database/otp-post-type.php';
require_once __DIR__ . '/otp-wo-database/otp-functions.php';
require_once __DIR__ . '/otp-wo-database/otp-shortcode.php';
require_once __DIR__ . '/otp-wo-database/otp-cb-request-otp.php';
require_once __DIR__ . '/otp-wo-database/otp-cb-verify-otp.php';
require_once __DIR__ . '/otp-wo-database/otp-cb-submit.php';
Code language: PHP (php)

CSS Styles

I am using the Twenty Twenty-One default theme, the following styles will most likely work with it to match what I have as my output. You can change it anyway you like.

#otp-review-form {
  background: #acdaff;
  padding: 30px;
}

#otp-review-form #otp-generate,
#otp-review-form #otp-verify,
#otp-review-form #finish {
  display: none;
}

#otp-review-form input,
#otp-review-form textarea {
  margin-bottom: 15px;
  width: 100%;
  border: none;
}

#otp-review-form a {
  display: inline-block;
  text-decoration: none;
  background: #009688;
  color: #fff;
  padding: 10px 40px;
  margin-top: 20px;
}

#otp-reviews h3.submitted-reviews {
  margin-bottom: 30px;
}

#otp-reviews .otp-review {
  padding: 20px;
  margin-bottom: 20px;
  background: #fff9c6;
}

/* Star rating CSS inspired from https://jsfiddle.net/0kwnb7ph/ */

#otp-review-form .txt-center {
  text-align: center;
}

#otp-review-form .hide {
  display: none;
}

#otp-review-form .clear {
  float: none;
  clear: both;
}

#otp-review-form .rating {
  width: 165px;
  unicode-bidi: bidi-override;
  direction: rtl;
  text-align: center;
  position: relative;
}

#otp-review-form .rating > label {
  float: right;
  display: inline;
  padding: 0;
  margin: 0;
  position: relative;
  width: 1.1em;
  cursor: pointer;
  color: #000;
  font-size: 30px !important;
}

#otp-review-form .rating > label:hover,
#otp-review-form .rating > label:hover ~ label,
#otp-review-form .rating > input.radio-btn:checked ~ label {
  color: transparent;
}

#otp-review-form .rating > label:hover:before,
#otp-review-form .rating > label:hover ~ label:before,
#otp-review-form .rating > input.radio-btn:checked ~ label:before,
#otp-review-form .rating > input.radio-btn:checked ~ label:before {
  content: "\2605";
  position: absolute;
  left: 0;
  color: #ff8c00;
}

.error {
	display: block;
	color: #e91e63;
	margin: 10px 0;
}

.review-pagination {
  margin-top: 20px;
}

.review-pagination .page-num {
  display: inline-block;
  padding: 5px 12px;
  margin-right: 5px;
  background: #eee;
  text-decoration: none;
  color: #333;
}

.review-pagination .page-num.current {
  background: #009688;
  color: #fff;
}
Code language: CSS (css)

Final Output

Let me know how it worked out for you, in the comments. I have more exciting tutorials on the line and possibly new features to this site as well so be sure to check around soon. Thanks!

Last Updated: April 17, 2026

About Al-Mamun Talukder

Full-Stack Developer, Minimalist Designer, and Tech Enthusiast. Car lover & Founder of Omnixima.

1 comment

  • Anonymous

    Hi!, such a fantastic resource. I really appreciate the effort you put into this!

Leave your comment

Related Articles

Ready to Build Something Amazing?

Whether you’re looking to create a new website, optimize your server, or solve a tricky technical problem, I’m here to help. Let’s collaborate and bring your ideas to life.
Schedule a Free Meeting