diff --git a/docs/checks.md b/docs/checks.md
index 758101825..6e362dfa8 100644
--- a/docs/checks.md
+++ b/docs/checks.md
@@ -9,6 +9,7 @@
| file_type | plugin_repo | Detects the usage of hidden and compressed files, VCS directories, application files and badly named files. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) |
| plugin_header_fields | plugin_repo | Checks adherence to the Headers requirements. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/header-requirements/) |
| late_escaping | security, plugin_repo | Checks that all output is escaped before being sent to the browser. | [Learn more](https://developer.wordpress.org/apis/security/escaping/) |
+| nonce_verification | security, plugin_repo | Checks for proper usage of wp_verify_nonce() to prevent CSRF vulnerabilities. | [Learn more](https://developer.wordpress.org/apis/security/nonces/) |
| plugin_updater | plugin_repo | Prevents altering WordPress update routines or using custom updaters, which are not allowed on WordPress.org. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) |
| plugin_review_phpcs | plugin_repo | Runs PHP_CodeSniffer to detect certain best practices plugins should follow for submission on WordPress.org. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/best-practices/) |
| direct_db_queries | security, plugin_repo | Checks the usage of direct database queries, which should be avoided. | [Learn more](https://developer.wordpress.org/apis/database/) |
diff --git a/includes/Checker/Checks/Security/Nonce_Verification_Check.php b/includes/Checker/Checks/Security/Nonce_Verification_Check.php
new file mode 100644
index 000000000..b1f6c8e9d
--- /dev/null
+++ b/includes/Checker/Checks/Security/Nonce_Verification_Check.php
@@ -0,0 +1,124 @@
+ 'php',
+ 'standard' => 'PluginCheck',
+ 'sniffs' => 'PluginCheck.Security.VerifyNonce',
+ 'installed_paths' => array(
+ WP_PLUGIN_CHECK_PLUGIN_DIR_PATH . 'phpcs-sniffs',
+ ),
+ );
+ }
+
+ /**
+ * Gets the description for the check.
+ *
+ * Every check must have a short description explaining what the check does.
+ *
+ * @since 1.7.0
+ *
+ * @return string Description.
+ */
+ public function get_description(): string {
+ return __( 'Checks for proper usage of wp_verify_nonce() to prevent CSRF vulnerabilities.', 'plugin-check' );
+ }
+
+ /**
+ * Gets the documentation URL for the check.
+ *
+ * Every check must have a URL with further information about the check.
+ *
+ * @since 1.7.0
+ *
+ * @return string The documentation URL.
+ */
+ public function get_documentation_url(): string {
+ return __( 'https://developer.wordpress.org/apis/security/nonces/', 'plugin-check' );
+ }
+
+ /**
+ * Amends the given result with a message for the specified file, including error information.
+ *
+ * @since 1.7.0
+ *
+ * @param Check_Result $result The check result to amend, including the plugin context to check.
+ * @param bool $error Whether it is an error or notice.
+ * @param string $message Error message.
+ * @param string $code Error code.
+ * @param string $file Absolute path to the file where the issue was found.
+ * @param int $line The line on which the message occurred. Default is 0 (unknown line).
+ * @param int $column The column on which the message occurred. Default is 0 (unknown column).
+ * @param string $docs URL for further information about the message.
+ * @param int $severity Severity level. Default is 5.
+ */
+ protected function add_result_message_for_file( Check_Result $result, $error, $message, $code, $file, $line = 0, $column = 0, string $docs = '', $severity = 5 ) {
+ switch ( $code ) {
+ case 'PluginCheck.Security.VerifyNonce.UnsafeVerifyNonceStatement':
+ $docs = __( 'https://developer.wordpress.org/reference/functions/check_admin_referer/', 'plugin-check' );
+ break;
+
+ case 'PluginCheck.Security.VerifyNonce.UnsafeVerifyNonceNegatedAnd':
+ $docs = __( 'https://developer.wordpress.org/apis/security/nonces/#verifying-nonces', 'plugin-check' );
+ break;
+
+ case 'PluginCheck.Security.VerifyNonce.UnsafeVerifyNonceElse':
+ $docs = __( 'https://developer.wordpress.org/apis/security/nonces/#verifying-nonces', 'plugin-check' );
+ break;
+
+ default:
+ $docs = __( 'https://developer.wordpress.org/apis/security/nonces/', 'plugin-check' );
+ break;
+ }
+
+ parent::add_result_message_for_file( $result, $error, $message, $code, $file, $line, $column, $docs, $severity );
+ }
+}
diff --git a/phpcs-sniffs/PluginCheck/Sniffs/Security/VerifyNonceSniff.php b/phpcs-sniffs/PluginCheck/Sniffs/Security/VerifyNonceSniff.php
new file mode 100644
index 000000000..7b24336c6
--- /dev/null
+++ b/phpcs-sniffs/PluginCheck/Sniffs/Security/VerifyNonceSniff.php
@@ -0,0 +1,513 @@
+getTokens();
+
+ // Check for wp_verify_nonce function calls.
+ if ( 'wp_verify_nonce' === $tokens[ $stackPtr ]['content'] ) {
+ $this->check_unconditional_call( $phpcsFile, $stackPtr, $tokens );
+ return;
+ }
+
+ // Check for if/elseif conditions.
+ if ( ! isset( $tokens[ $stackPtr ]['parenthesis_opener'] ) || ! isset( $tokens[ $stackPtr ]['parenthesis_closer'] ) ) {
+ return;
+ }
+
+ $opener = $tokens[ $stackPtr ]['parenthesis_opener'];
+ $closer = $tokens[ $stackPtr ]['parenthesis_closer'];
+
+ // Find wp_verify_nonce in this condition.
+ $noncePtr = $this->find_function_call( $phpcsFile, $opener, $closer, 'wp_verify_nonce' );
+ if ( false === $noncePtr ) {
+ return;
+ }
+
+ // Check if it's negated.
+ $isNegated = $this->is_negated( $phpcsFile, $noncePtr, $tokens );
+
+ // Check for isset() && !wp_verify_nonce() pattern.
+ if ( $isNegated && $this->has_isset_before_and( $phpcsFile, $noncePtr, $opener, $tokens ) ) {
+ $this->report_isset_and_negated_nonce( $phpcsFile, $noncePtr );
+ return;
+ }
+
+ // Check for !isset() && !wp_verify_nonce() pattern.
+ if ( $isNegated && $this->has_negated_isset_before_and( $phpcsFile, $noncePtr, $opener, $tokens ) ) {
+ $this->report_negated_isset_and_negated_nonce( $phpcsFile, $noncePtr, $stackPtr, $tokens );
+ return;
+ }
+
+ // Check for $something || wp_verify_nonce() with else that exits.
+ if ( ! $isNegated && $this->has_or_before_nonce( $phpcsFile, $noncePtr, $opener, $tokens ) ) {
+ $this->check_or_condition_with_else( $phpcsFile, $noncePtr, $stackPtr, $tokens );
+ }
+ }
+
+ /**
+ * Check for unconditional wp_verify_nonce() call (not in conditional, return, or assignment).
+ *
+ * @since 1.7.0
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $stackPtr The position of the current token.
+ * @param array $tokens The stack of tokens.
+ *
+ * @return void
+ */
+ private function check_unconditional_call( File $phpcsFile, $stackPtr, $tokens ) {
+ // Check if it's in a conditional expression.
+ if ( $this->is_in_conditional( $phpcsFile, $stackPtr, $tokens ) ) {
+ return;
+ }
+
+ // Check if it's a return statement.
+ if ( $this->is_return_statement( $phpcsFile, $stackPtr, $tokens ) ) {
+ return;
+ }
+
+ // Check if it's an assignment.
+ if ( $this->is_assignment( $phpcsFile, $stackPtr, $tokens ) ) {
+ return;
+ }
+
+ // Check if it's a ternary expression.
+ if ( $this->is_in_ternary( $phpcsFile, $stackPtr, $tokens ) ) {
+ return;
+ }
+
+ $phpcsFile->addError(
+ 'Unconditional call to wp_verify_nonce(). The return value must be checked. Consider using check_admin_referer() instead, which exits on failure.',
+ $stackPtr,
+ 'UnsafeVerifyNonceStatement'
+ );
+ }
+
+ /**
+ * Check if the token is in a conditional expression.
+ *
+ * @since 1.7.0
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $stackPtr The position of the current token.
+ * @param array $tokens The stack of tokens.
+ *
+ * @return bool
+ */
+ private function is_in_conditional( File $phpcsFile, $stackPtr, $tokens ) {
+ // Check if we're inside an if/elseif/while/for condition.
+ $conditions = array( T_IF, T_ELSEIF, T_WHILE, T_FOR, T_FOREACH );
+
+ // Look backward for a condition.
+ $openParen = $phpcsFile->findPrevious( T_OPEN_PARENTHESIS, $stackPtr - 1 );
+ if ( false !== $openParen && isset( $tokens[ $openParen ]['parenthesis_owner'] ) ) {
+ $owner = $tokens[ $openParen ]['parenthesis_owner'];
+ if ( in_array( $tokens[ $owner ]['code'], $conditions, true ) ) {
+ // Check if we're between the parentheses.
+ if ( isset( $tokens[ $openParen ]['parenthesis_closer'] ) && $stackPtr < $tokens[ $openParen ]['parenthesis_closer'] ) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if it's a return statement.
+ *
+ * @since 1.7.0
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $stackPtr The position of the current token.
+ * @param array $tokens The stack of tokens.
+ *
+ * @return bool
+ */
+ private function is_return_statement( File $phpcsFile, $stackPtr, $tokens ) {
+ $prev = $phpcsFile->findPrevious( Tokens::$emptyTokens, $stackPtr - 1, null, true );
+ return false !== $prev && T_RETURN === $tokens[ $prev ]['code'];
+ }
+
+ /**
+ * Check if it's an assignment.
+ *
+ * @since 1.7.0
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $stackPtr The position of the current token.
+ * @param array $tokens The stack of tokens.
+ *
+ * @return bool
+ */
+ private function is_assignment( File $phpcsFile, $stackPtr, $tokens ) {
+ $closeParen = $phpcsFile->findNext( T_CLOSE_PARENTHESIS, $stackPtr );
+ if ( false === $closeParen ) {
+ return false;
+ }
+
+ $next = $phpcsFile->findNext( Tokens::$emptyTokens, $closeParen + 1, null, true );
+ return false !== $next && T_SEMICOLON === $tokens[ $next ]['code'];
+ }
+
+ /**
+ * Check if it's in a ternary expression.
+ *
+ * @since 1.7.0
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $stackPtr The position of the current token.
+ * @param array $tokens The stack of tokens.
+ *
+ * @return bool
+ */
+ private function is_in_ternary( File $phpcsFile, $stackPtr, $tokens ) {
+ // Look for a ternary operator after the function call.
+ $closeParen = $phpcsFile->findNext( T_CLOSE_PARENTHESIS, $stackPtr );
+ if ( false === $closeParen ) {
+ return false;
+ }
+
+ $semicolon = $phpcsFile->findNext( T_SEMICOLON, $closeParen );
+ if ( false === $semicolon ) {
+ return false;
+ }
+
+ for ( $i = $closeParen; $i < $semicolon; $i++ ) {
+ if ( T_INLINE_THEN === $tokens[ $i ]['code'] ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Find a function call within a range.
+ *
+ * @since 1.7.0
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $start Start position.
+ * @param int $end End position.
+ * @param string $function Function name to find.
+ *
+ * @return int|false
+ */
+ private function find_function_call( File $phpcsFile, $start, $end, $function ) {
+ $tokens = $phpcsFile->getTokens();
+
+ for ( $i = $start + 1; $i < $end; $i++ ) {
+ if ( isset( $tokens[ $i ]['content'] ) && $function === $tokens[ $i ]['content'] ) {
+ return $i;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if wp_verify_nonce() is negated.
+ *
+ * @since 1.7.0
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $stackPtr The position of the current token.
+ * @param array $tokens The stack of tokens.
+ *
+ * @return bool
+ */
+ private function is_negated( File $phpcsFile, $stackPtr, $tokens ) {
+ $prev = $phpcsFile->findPrevious( Tokens::$emptyTokens, $stackPtr - 1, null, true );
+ return false !== $prev && T_BOOLEAN_NOT === $tokens[ $prev ]['code'];
+ }
+
+ /**
+ * Check if there's isset() before an AND operator before the nonce check.
+ *
+ * @since 1.7.0
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $noncePtr The position of wp_verify_nonce.
+ * @param int $opener The opening parenthesis of the condition.
+ * @param array $tokens The stack of tokens.
+ *
+ * @return bool
+ */
+ private function has_isset_before_and( File $phpcsFile, $noncePtr, $opener, $tokens ) {
+ // Find AND operator before nonce.
+ $andPtr = $this->find_operator_before( $phpcsFile, $noncePtr, $opener, $tokens, array( T_BOOLEAN_AND, T_LOGICAL_AND ) );
+ if ( false === $andPtr ) {
+ return false;
+ }
+
+ // Check if there's isset() before the AND.
+ $issetPtr = $this->find_function_call( $phpcsFile, $opener, $andPtr, 'isset' );
+ return false !== $issetPtr;
+ }
+
+ /**
+ * Check if there's !isset() before an AND operator before the nonce check.
+ *
+ * @since 1.7.0
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $noncePtr The position of wp_verify_nonce.
+ * @param int $opener The opening parenthesis of the condition.
+ * @param array $tokens The stack of tokens.
+ *
+ * @return bool
+ */
+ private function has_negated_isset_before_and( File $phpcsFile, $noncePtr, $opener, $tokens ) {
+ // Find AND operator before nonce.
+ $andPtr = $this->find_operator_before( $phpcsFile, $noncePtr, $opener, $tokens, array( T_BOOLEAN_AND, T_LOGICAL_AND ) );
+ if ( false === $andPtr ) {
+ return false;
+ }
+
+ // Check if there's isset() before the AND.
+ $issetPtr = $this->find_function_call( $phpcsFile, $opener, $andPtr, 'isset' );
+ if ( false === $issetPtr ) {
+ return false;
+ }
+
+ // Check if isset is negated.
+ return $this->is_negated( $phpcsFile, $issetPtr, $tokens );
+ }
+
+ /**
+ * Check if there's an OR operator before the nonce check.
+ *
+ * @since 1.7.0
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $noncePtr The position of wp_verify_nonce.
+ * @param int $opener The opening parenthesis of the condition.
+ * @param array $tokens The stack of tokens.
+ *
+ * @return bool
+ */
+ private function has_or_before_nonce( File $phpcsFile, $noncePtr, $opener, $tokens ) {
+ $orPtr = $this->find_operator_before( $phpcsFile, $noncePtr, $opener, $tokens, array( T_BOOLEAN_OR, T_LOGICAL_OR ) );
+ if ( false === $orPtr ) {
+ return false;
+ }
+
+ // Make sure there's no wp_verify_nonce before the OR.
+ $firstNoncePtr = $this->find_function_call( $phpcsFile, $opener, $orPtr, 'wp_verify_nonce' );
+ return false === $firstNoncePtr;
+ }
+
+ /**
+ * Find an operator before the current position.
+ *
+ * @since 1.7.0
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $stackPtr The position of the current token.
+ * @param int $start Start position to search from.
+ * @param array $tokens The stack of tokens.
+ * @param array $operatorTypes Array of operator token types to search for.
+ *
+ * @return int|false
+ */
+ private function find_operator_before( File $phpcsFile, $stackPtr, $start, $tokens, $operatorTypes ) {
+ for ( $i = $stackPtr - 1; $i > $start; $i-- ) {
+ if ( in_array( $tokens[ $i ]['code'], $operatorTypes, true ) ) {
+ return $i;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Report isset() && !wp_verify_nonce() pattern.
+ *
+ * @since 1.7.0
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $stackPtr The position of the current token.
+ *
+ * @return void
+ */
+ private function report_isset_and_negated_nonce( File $phpcsFile, $stackPtr ) {
+ $phpcsFile->addError(
+ 'Unsafe use of wp_verify_nonce() with isset() and AND operator. If isset() is false, the nonce is never checked. Use OR operator instead: if ( ! isset(...) || ! wp_verify_nonce(...) )',
+ $stackPtr,
+ 'UnsafeVerifyNonceIssetAnd'
+ );
+ }
+
+ /**
+ * Report !isset() && !wp_verify_nonce() pattern.
+ *
+ * @since 1.7.0
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $stackPtr The position of the current token.
+ * @param int $condPtr The position of the if/elseif.
+ * @param array $tokens The stack of tokens.
+ *
+ * @return void
+ */
+ private function report_negated_isset_and_negated_nonce( File $phpcsFile, $stackPtr, $condPtr, $tokens ) {
+ // Check if the condition scope contains error terminator.
+ if ( ! $this->scope_contains_error_terminator( $phpcsFile, $condPtr, $tokens ) ) {
+ return;
+ }
+
+ $phpcsFile->addError(
+ 'Unsafe use of wp_verify_nonce() with !isset() and AND operator. If isset() is true (nonce exists), the nonce is never checked. Use OR operator instead: if ( ! isset(...) || ! wp_verify_nonce(...) )',
+ $stackPtr,
+ 'UnsafeVerifyNonceNegatedAnd'
+ );
+ }
+
+ /**
+ * Check OR condition with else clause.
+ *
+ * @since 1.7.0
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $stackPtr The position of wp_verify_nonce.
+ * @param int $condPtr The position of the if/elseif.
+ * @param array $tokens The stack of tokens.
+ *
+ * @return void
+ */
+ private function check_or_condition_with_else( File $phpcsFile, $stackPtr, $condPtr, $tokens ) {
+ // Check if there's an else clause.
+ if ( ! isset( $tokens[ $condPtr ]['scope_closer'] ) ) {
+ return;
+ }
+
+ $elsePtr = $phpcsFile->findNext( T_ELSE, $tokens[ $condPtr ]['scope_closer'], null, false );
+ if ( false === $elsePtr ) {
+ return;
+ }
+
+ // Check if else scope contains error terminator.
+ if ( ! isset( $tokens[ $elsePtr ]['scope_opener'] ) || ! isset( $tokens[ $elsePtr ]['scope_closer'] ) ) {
+ return;
+ }
+
+ if ( ! $this->scope_contains_error_terminator_in_range( $phpcsFile, $tokens[ $elsePtr ]['scope_opener'], $tokens[ $elsePtr ]['scope_closer'], $tokens ) ) {
+ return;
+ }
+
+ $phpcsFile->addWarning(
+ 'Possibly unsafe use of wp_verify_nonce() with OR operator. If the condition before || is true, the nonce is never checked. Move nonce verification before the || or use separate conditions.',
+ $stackPtr,
+ 'UnsafeVerifyNonceElse'
+ );
+ }
+
+ /**
+ * Check if scope contains an error terminator (exit, die, return, etc.).
+ *
+ * @since 1.7.0
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $condition The condition pointer.
+ * @param array $tokens The stack of tokens.
+ *
+ * @return bool
+ */
+ private function scope_contains_error_terminator( File $phpcsFile, $condition, $tokens ) {
+ if ( ! isset( $tokens[ $condition ]['scope_opener'] ) || ! isset( $tokens[ $condition ]['scope_closer'] ) ) {
+ // Check for single-line if without braces.
+ $semicolon = $phpcsFile->findNext( T_SEMICOLON, $condition, null, false );
+ if ( false !== $semicolon ) {
+ return $this->scope_contains_error_terminator_in_range( $phpcsFile, $condition, $semicolon, $tokens );
+ }
+ return false;
+ }
+
+ return $this->scope_contains_error_terminator_in_range(
+ $phpcsFile,
+ $tokens[ $condition ]['scope_opener'],
+ $tokens[ $condition ]['scope_closer'],
+ $tokens
+ );
+ }
+
+ /**
+ * Check if a range contains an error terminator.
+ *
+ * @since 1.7.0
+ *
+ * @param File $phpcsFile The file being scanned.
+ * @param int $start Start position.
+ * @param int $end End position.
+ * @param array $tokens The stack of tokens.
+ *
+ * @return bool
+ */
+ private function scope_contains_error_terminator_in_range( File $phpcsFile, $start, $end, $tokens ) {
+ $terminators = array(
+ 'exit',
+ 'die',
+ 'wp_send_json_error',
+ 'wp_nonce_ays',
+ 'wp_die',
+ );
+
+ for ( $i = $start; $i < $end; $i++ ) {
+ if ( T_RETURN === $tokens[ $i ]['code'] ) {
+ return true;
+ }
+
+ if ( isset( $tokens[ $i ]['content'] ) && in_array( $tokens[ $i ]['content'], $terminators, true ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/phpcs-sniffs/PluginCheck/Tests/Security/VerifyNonceUnitTest.inc b/phpcs-sniffs/PluginCheck/Tests/Security/VerifyNonceUnitTest.inc
new file mode 100644
index 000000000..07b860706
--- /dev/null
+++ b/phpcs-sniffs/PluginCheck/Tests/Security/VerifyNonceUnitTest.inc
@@ -0,0 +1,169 @@
+get_nonce_action() ); // safe
+}
+
+// GOOD: Nonce check with parentheses
+function safe_example_4() {
+ if ( ( wp_verify_nonce( $nonce, 'my-nonce' ) ) ) {
+ do_something();
+ } else {
+ die();
+ }
+}
+
+// GOOD: Nonce assigned to variable first
+function safe_example_5() {
+ $check = wp_verify_nonce(sanitize_text_field($_POST['security']), 'my-nonce');
+ if (!$check)
+ return;
+}
+
+// GOOD: Ternary with isset
+function safe_example_6() {
+ $is_valid = ( isset( $_POST[ 'my_nonce' ] ) && wp_verify_nonce( $_POST[ 'my_nonce' ], 'something' ) ) ? true : false;
+ return $is_valid;
+}
+
+// GOOD: Multiple nonce checks with OR
+function safe_example_7() {
+ if ( wp_verify_nonce( $_REQUEST['_wpnonce'], 'wpdocs-my-nonce-1' ) || wp_verify_nonce( $_REQUEST['_wpnonce'], 'wpdocs-my-nonce-2' ) ) { // safe!
+ //do you action
+ } else {
+ die( __( 'Security check', 'textdomain' ) );
+ }
+}
+
+// GOOD: Multiple nonce checks with AND in negative condition
+function safe_example_8() {
+ if ( !wp_verify_nonce( $_REQUEST['_wpnonce'], 'wpdocs-my-nonce-1' ) && !wp_verify_nonce( $_REQUEST['_wpnonce'], 'wpdocs-my-nonce-2' ) ) { // safe!
+ die( __( 'Security check', 'textdomain' ) );
+ } else {
+ // do secure action
+ }
+}
+
+// GOOD: Nonce check first in AND condition
+function safe_example_9() {
+ if ( wp_verify_nonce( $nonce, 'my-nonce' ) && $something_else ) {
+ // do secure action
+ } else {
+ die( __( 'Security check', 'textdomain' ) );
+ }
+}
+
+// GOOD: Simple if/else without braces
+function safe_example_10() {
+ if ( wp_verify_nonce( $nonce, 'my-nonce' ) )
+ do_something();
+ else
+ die;
+}
+
+// GOOD: Short-circuit with early return is safe
+function safe_example_11() {
+ if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || ! wp_verify_nonce( $nonce, 'csf_taxonomy_nonce' ) ) { // safe!
+ return;
+ }
+
+ // do secure action
+}
+
+// GOOD: Use check_admin_referer() for unconditional checks
+check_admin_referer( 'my_action' );
+update_option( 'my_option', $_POST['value'] );
diff --git a/phpcs-sniffs/PluginCheck/Tests/Security/VerifyNonceUnitTest.php b/phpcs-sniffs/PluginCheck/Tests/Security/VerifyNonceUnitTest.php
new file mode 100644
index 000000000..7da47b530
--- /dev/null
+++ b/phpcs-sniffs/PluginCheck/Tests/Security/VerifyNonceUnitTest.php
@@ -0,0 +1,65 @@
+ =>
+ */
+ public function getErrorList() {
+ return array(
+ 8 => 1, // insecure_nonce_1: Nonce not checked if it's unset (isset && !wp_verify_nonce).
+ 15 => 1, // insecure_nonce_2: Nonce not checked if it's unset (isset && !wp_verify_nonce).
+ 21 => 1, // insecure_nonce_3: Nonce not checked if it's unset (isset && !wp_verify_nonce).
+ 28 => 1, // insecure_nonce_4: Unconditional wp_verify_nonce call.
+ 42 => 1, // insecure_nonce_6: AND instead of OR (!isset && !wp_verify_nonce).
+ 47 => 1, // insecure_nonce_7: AND instead of OR (!isset && !wp_verify_nonce).
+ );
+ }
+
+ /**
+ * Returns the lines where warnings should occur.
+ *
+ * @return array =>
+ */
+ public function getWarningList() {
+ return array(
+ 33 => 1, // insecure_nonce_5: OR condition with else.
+ 56 => 1, // insecure_nonce_8: OR condition without proper else handling.
+ );
+ }
+
+ /**
+ * Returns the fully qualified class name (FQCN) of the sniff.
+ *
+ * @return string The fully qualified class name of the sniff.
+ */
+ protected function get_sniff_fqcn() {
+ return VerifyNonceSniff::class;
+ }
+
+ /**
+ * Sets the parameters for the sniff.
+ *
+ * @throws \RuntimeException If unable to set the ruleset parameters required for the test.
+ *
+ * @param Sniff $sniff The sniff being tested.
+ */
+ public function set_sniff_parameters( Sniff $sniff ) {
+ }
+}
diff --git a/phpcs-sniffs/PluginCheck/ruleset.xml b/phpcs-sniffs/PluginCheck/ruleset.xml
index c39c7295d..87c071074 100644
--- a/phpcs-sniffs/PluginCheck/ruleset.xml
+++ b/phpcs-sniffs/PluginCheck/ruleset.xml
@@ -9,5 +9,6 @@
+