Index: /wplr-sync/trunk/classes/api.php
===================================================================
--- /wplr-sync/trunk/classes/api.php	(revision 3737476)
+++ /wplr-sync/trunk/classes/api.php	(revision 3737477)
@@ -40,5 +40,5 @@
 		// Upload
 		else if ( isset( $_POST['action'] ) && $_POST['action'] === 'sync' ) {
-			if ( !$user = $this->auth( $_POST['token'] ) ) {
+			if ( !$user = $this->auth( isset( $_POST['token'] ) ? wp_unslash( $_POST['token'] ) : null ) ) {
 				$this->response( null, false, $this->error );
 				$this->finalize();
@@ -51,12 +51,14 @@
 			$body = file_get_contents('php://input');
 			$args = json_decode( $body );
-			if ( !$user = $this->auth( $args->token ) ) {
+			if ( !is_object( $args ) || !isset( $args->action ) ) {
+				$this->response( null, false, "Invalid request." );
+				$this->finalize();
+			}
+			if ( !$user = $this->auth( isset( $args->token ) ? $args->token : null ) ) {
 				$this->response( null, false, $this->error );
 				$this->finalize();
 			}
 			$wplr->log( "Action: " . $args->action );
-			if ( !$user = $this->auth( $args->token ) )
-				$this->response( null, false, $this->error );
-			else if ( $args->action === 'presync' )
+			if ( $args->action === 'presync' )
 				$this->presync( $args );
 			else if ( $args->action === 'sync_collection' )
@@ -117,35 +119,33 @@
 		global $wplr, $wpdb;
 
-
-		if ( empty( $token ) ) {
+		// Failed attempts are counted per IP: a token can't be guessed by hammering the endpoint.
+		$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
+		$fail_key = 'wplr_api_fail_' . md5( $ip );
+		$fails = (int) get_transient( $fail_key );
+		if ( $fails >= 20 ) {
+			$this->error = "Too many failed attempts. Please try again in 15 minutes.";
+			$wplr->log( $this->error );
+			return false;
+		}
+
+		if ( !is_string( $token ) || strlen( $token ) < 12 || strlen( $token ) > 128 ) {
 			$this->error = "Authentification is missing.";
 			$wplr->log( $this->error );
+			set_transient( $fail_key, $fails + 1, 15 * MINUTE_IN_SECONDS );
 			return false;
 		}
-		
-		//* DEBUG: allow to authenticate as admin with a hardcoded token (for testing purposes only)
-		// if ( $token == "MEOW_TOKEN" ) {
-
-		// 	$this->user = get_userdata( 1 );
-		// 	if ( empty( $this->user ) ) {
-		// 		$this->error = "User admin does not exist.";
-		// 		$wplr->log( $this->error );
-		// 		return false;
-		// 	}
-
-		// 	$wplr->log( "Authenticated as admin" );
-		// 	return $this->user;
-		// }
 
 		$prefix = defined( 'BLOG_ID_CURRENT_SITE' ) ? $wpdb->get_blog_prefix( BLOG_ID_CURRENT_SITE ) : $wpdb->prefix;
 		$tbl_meta = defined('CUSTOM_USER_META_TABLE' ) ? CUSTOM_USER_META_TABLE : $prefix . "usermeta";
-		$userId = $wpdb->get_var( $wpdb->prepare( "SELECT user_id FROM $tbl_meta
+		$row = $wpdb->get_row( $wpdb->prepare( "SELECT user_id, meta_value FROM $tbl_meta
 			WHERE meta_value = %s AND meta_key = %s", $token, 'wplr_auth_token' ) );
-		if ( empty( $userId ) ) {
-			$this->error = "Token '$token' seems to be invalid.";
+		// The SQL match can be case-insensitive (collation): the token must match exactly.
+		if ( empty( $row ) || !is_string( $row->meta_value ) || !hash_equals( $row->meta_value, $token ) ) {
+			$this->error = "This token seems to be invalid.";
 			$wplr->log( $this->error );
+			set_transient( $fail_key, $fails + 1, 15 * MINUTE_IN_SECONDS );
 			return false;
 		}
-		$this->user = get_userdata( $userId );
+		$this->user = get_userdata( $row->user_id );
 		if ( empty( $this->user ) ) {
 			$this->error = 'User does not exist.';
@@ -153,6 +153,31 @@
 			return false;
 		}
+
+		// Act as this user, so every capability check below applies to them.
+		wp_set_current_user( $this->user->ID );
+		if ( !current_user_can( 'upload_files' ) ) {
+			$this->error = 'This user is not allowed to upload files.';
+			$wplr->log( $this->error );
+			$this->user = null;
+			return false;
+		}
 		$wplr->log( "Authenticated as " . $this->user->user_login );
 		return $this->user;
+	}
+
+	// The attachment(s) behind a Lightroom ID must be editable (or deletable) by the authenticated user.
+	function can_on_lr_id( $lr_id, $cap ) {
+		global $wplr;
+		foreach ( (array) $wplr->list_wpids( (int) $lr_id ) as $row ) {
+			if ( !empty( $row->wp_id ) && !current_user_can( $cap, (int) $row->wp_id ) ) {
+				return false;
+			}
+		}
+		return true;
+	}
+
+	function can_on_wp_id( $wp_id, $cap ) {
+		$wp_id = (int) $wp_id;
+		return $wp_id > 0 && get_post_type( $wp_id ) === 'attachment' && current_user_can( $cap, $wp_id );
 	}
 
@@ -237,4 +262,9 @@
 		$file = $_FILES['file']['tmp_name'];
 
+		// Syncing over an existing photo replaces its file: the user must be allowed to edit it.
+		if ( !$this->can_on_lr_id( $args["id"], 'edit_post' ) ) {
+			return $this->response( null, false, 'You are not allowed to edit this media.' );
+		}
+
 		if ( !isset( $args["wp_col_id"] ) || $args["wp_col_id"] == null )
 			$args["wp_col_id"] = -1;
@@ -301,4 +331,8 @@
 		global $wplr;
 		$res = false;
+		// Deleting a collection also deletes its photos, which may belong to anyone.
+		if ( !current_user_can( 'delete_others_posts' ) ) {
+			return $this->response( null, false, 'You are not allowed to delete collections.' );
+		}
 		if ( property_exists( $args, 'remoteId' ) )
 			$res = $wplr->delete_collection( $args->remoteId );
@@ -312,5 +346,8 @@
 		if ( property_exists( $args, 'collection_id' ) )
 			$collectionId = $args->collection_id;
-		$list = $wplr->delete_media( $args->lr_id, $collectionId );
+		if ( !property_exists( $args, 'lr_id' ) || !$this->can_on_lr_id( $args->lr_id, 'delete_post' ) ) {
+			return $this->response( null, false, 'You are not allowed to delete this media.' );
+		}
+		$list = $wplr->delete_media( (int) $args->lr_id, $collectionId );
 		return $this->response( $list );
 	}
@@ -335,5 +372,10 @@
 	function unlink( $args ) {
 		global $wplr;
-		$res = $wplr->unlink_media( $args->lr_id, $args->wp_id );
+		$wp_id = property_exists( $args, 'wp_id' ) ? (int) $args->wp_id : 0;
+		$lr_id = property_exists( $args, 'lr_id' ) ? (int) $args->lr_id : 0;
+		if ( $wp_id ? !$this->can_on_wp_id( $wp_id, 'edit_post' ) : !$this->can_on_lr_id( $lr_id, 'edit_post' ) ) {
+			return $this->response( null, false, 'You are not allowed to edit this media.' );
+		}
+		$res = $wplr->unlink_media( $lr_id, $wp_id );
 		return $this->response( $res );
 	}
@@ -342,5 +384,10 @@
 	function link( $args ) {
 		global $wplr;
-		$res = $wplr->link_media( $args->lr_id, $args->wp_id );
+		// Linking decides which attachment a later sync or delete will touch.
+		$wp_id = property_exists( $args, 'wp_id' ) ? (int) $args->wp_id : 0;
+		if ( !$this->can_on_wp_id( $wp_id, 'edit_post' ) ) {
+			return $this->response( null, false, 'You are not allowed to link this media.' );
+		}
+		$res = $wplr->link_media( (int) $args->lr_id, $wp_id );
 		return $this->response( $res );
 	}
@@ -357,5 +404,8 @@
 	function linkinfo( $args ) {
 		global $wplr;
-		$linkinfo = $wplr->linkinfo_media( $args->wp_id );
+		if ( !$this->can_on_wp_id( property_exists( $args, 'wp_id' ) ? $args->wp_id : 0, 'edit_post' ) ) {
+			return $this->response( null, false, 'You are not allowed to access this media.' );
+		}
+		$linkinfo = $wplr->linkinfo_media( (int) $args->wp_id );
 		return $this->response( $linkinfo );
 	}
@@ -365,4 +415,7 @@
 		global $wplr;
 		$wp_ids = isset( $args->wp_ids ) ? (array)$args->wp_ids : array();
+		$wp_ids = array_values( array_filter( array_map( 'intval', $wp_ids ), function ( $id ) {
+			return $this->can_on_wp_id( $id, 'edit_post' );
+		} ) );
 		$list = $wplr->linkinfo_bulk( $wp_ids );
 		return $this->response( $list );
Index: /wplr-sync/trunk/classes/core.php
===================================================================
--- /wplr-sync/trunk/classes/core.php	(revision 3737476)
+++ /wplr-sync/trunk/classes/core.php	(revision 3737477)
@@ -260,4 +260,8 @@
 
 	function profile_update( $user_id ) {
+		// Only users who can upload get a Lightroom token; the others have no use for one.
+		if ( !user_can( $user_id, 'upload_files' ) ) {
+			return;
+		}
 		$token = get_user_meta( $user_id, 'wplr_auth_token', true );
 		if ( empty( $token ) ) {
@@ -272,18 +276,7 @@
 	 */
 	function generate_auth_token( $userId ) {
-		static $MIN_LENGTH = 24;
-		static $MAX_LENGTH = 32;
-
-		// Seed
-		list( $usec, $sec ) = explode( ' ', microtime() );
-		$seed = $sec + $usec * 1000000;
-		srand( $seed );
-
-		// Compose
-		$r = '';
-		$chars = str_split( '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' );
-		$nChars = count( $chars );
-		$length = rand( $MIN_LENGTH, $MAX_LENGTH );
-		for ( $i = 0; $i < $length; $i++ ) $r .= $chars[rand( 0, $nChars - 1 )];
+		// A cryptographically secure token. It used to come from rand() seeded with the time,
+		// which made it guessable from the moment it was created.
+		$r = wp_generate_password( 32, false, false );
 
 		// Save
Index: /wplr-sync/trunk/readme.txt
===================================================================
--- /wplr-sync/trunk/readme.txt	(revision 3737476)
+++ /wplr-sync/trunk/readme.txt	(revision 3737477)
@@ -7,5 +7,5 @@
 Tested up to: 7.1
 Requires PHP: 8.1
-Stable tag: 6.5.6
+Stable tag: 6.5.7
 License: GPLv2 or later
 License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -63,4 +63,11 @@
 
 == Changelog ==
+
+= 6.5.7 (2026/10/10) =
+* Fix: Security: The Lightroom sync API now requires a user who can upload files and checks their rights on every photo it edits, links or deletes.
+* Fix: Security: Lightroom sync tokens are now unguessable and repeated failed attempts are blocked.
+* 🎵 Discuss with others about Wplr Sync on [the Discord](https://discord.gg/bHDGh38).
+* 🌴 Keep us motivated with [a little review here](https://wordpress.org/support/plugin/wplr-sync/reviews/). Thank you!
+* 🥰 If you want to help us, check our [Patreon](https://www.patreon.com/meowapps). Thank you!
 
 = 6.5.6 (2026/09/26) =
Index: /wplr-sync/trunk/wplr-sync.php
===================================================================
--- /wplr-sync/trunk/wplr-sync.php	(revision 3737476)
+++ /wplr-sync/trunk/wplr-sync.php	(revision 3737477)
@@ -4,5 +4,5 @@
 Plugin URI: https://meowapps.com
 Description: Synchronize and maintain your photos, collections, keywords and metadata between Lightroom and WordPress.
-Version: 6.5.6
+Version: 6.5.7
 Author: Jordy Meow
 Author URI: https://meowapps.com
@@ -15,5 +15,5 @@
 */
 
-define( 'WPLR_SYNC_VERSION', '6.5.6' );
+define( 'WPLR_SYNC_VERSION', '6.5.7' );
 define( 'WPLR_SYNC_PREFIX', 'wplr_sync' );
 define( 'WPLR_SYNC_DOMAIN', 'wplr-sync' );
