diff --git a/bridges/bin/millau/runtime/src/lib.rs b/bridges/bin/millau/runtime/src/lib.rs
index 4041cfe171b3088a5cc47fd06df8b383e8e26435..4ee80e9e124d311bb9b598966bcf458d62c3595e 100644
--- a/bridges/bin/millau/runtime/src/lib.rs
+++ b/bridges/bin/millau/runtime/src/lib.rs
@@ -1035,6 +1035,7 @@ impl_runtime_apis! {
 					pallet_bridge_parachains::RelayBlockNumber,
 					pallet_bridge_parachains::RelayBlockHash,
 					bp_polkadot_core::parachains::ParaHeadsProof,
+					Vec<(bp_polkadot_core::parachains::ParaId, bp_polkadot_core::parachains::ParaHash)>,
 				) {
 					bridge_runtime_common::parachains_benchmarking::prepare_parachain_heads_proof::<Runtime, WithRialtoParachainsInstance>(
 						parachains,
diff --git a/bridges/bin/runtime-common/src/messages_extension.rs b/bridges/bin/runtime-common/src/messages_extension.rs
index fd1d9d7a934b5e242477b2d6c1bbcfba04f54c73..48b30fa646e2a92653e09a9e02dda5bf6fcf4615 100644
--- a/bridges/bin/runtime-common/src/messages_extension.rs
+++ b/bridges/bin/runtime-common/src/messages_extension.rs
@@ -70,6 +70,14 @@ macro_rules! declare_bridge_reject_obsolete_messages {
 
 							let inbound_lane_data = pallet_bridge_messages::InboundLanes::<$runtime, $instance>::get(&proof.lane);
 							if proof.nonces_end <= inbound_lane_data.last_delivered_nonce() {
+								log::trace!(
+									target: pallet_bridge_messages::LOG_TARGET,
+									"Rejecting obsolete messages delivery transaction: lane {:?}, bundled {:?}, best {:?}",
+									proof.lane,
+									proof.nonces_end,
+									inbound_lane_data.last_delivered_nonce(),
+								);
+
 								return sp_runtime::transaction_validity::InvalidTransaction::Stale.into();
 							}
 
@@ -84,6 +92,14 @@ macro_rules! declare_bridge_reject_obsolete_messages {
 
 							let outbound_lane_data = pallet_bridge_messages::OutboundLanes::<$runtime, $instance>::get(&proof.lane);
 							if latest_delivered_nonce <= outbound_lane_data.latest_received_nonce {
+								log::trace!(
+									target: pallet_bridge_messages::LOG_TARGET,
+									"Rejecting obsolete messages confirmation transaction: lane {:?}, bundled {:?}, best {:?}",
+									proof.lane,
+									latest_delivered_nonce,
+									outbound_lane_data.latest_received_nonce,
+								);
+
 								return sp_runtime::transaction_validity::InvalidTransaction::Stale.into();
 							}
 
diff --git a/bridges/bin/runtime-common/src/parachains_benchmarking.rs b/bridges/bin/runtime-common/src/parachains_benchmarking.rs
index f707f652d8c3869cd093ea10ff472924f27318b3..97a1cd3ee7d9faf39ce719dfe070fcea7afa08cc 100644
--- a/bridges/bin/runtime-common/src/parachains_benchmarking.rs
+++ b/bridges/bin/runtime-common/src/parachains_benchmarking.rs
@@ -21,7 +21,7 @@
 use crate::messages_benchmarking::{grow_trie, insert_header_to_grandpa_pallet};
 
 use bp_parachains::parachain_head_storage_key_at_source;
-use bp_polkadot_core::parachains::{ParaHead, ParaHeadsProof, ParaId};
+use bp_polkadot_core::parachains::{ParaHash, ParaHead, ParaHeadsProof, ParaId};
 use bp_runtime::StorageProofSize;
 use codec::Encode;
 use frame_support::traits::Get;
@@ -37,7 +37,7 @@ pub fn prepare_parachain_heads_proof<R, PI>(
 	parachains: &[ParaId],
 	parachain_head_size: u32,
 	size: StorageProofSize,
-) -> (RelayBlockNumber, RelayBlockHash, ParaHeadsProof)
+) -> (RelayBlockNumber, RelayBlockHash, ParaHeadsProof, Vec<(ParaId, ParaHash)>)
 where
 	R: pallet_bridge_parachains::Config<PI>
 		+ pallet_bridge_grandpa::Config<R::BridgesGrandpaPalletInstance>,
@@ -48,6 +48,7 @@ where
 	let parachain_head = ParaHead(vec![0u8; parachain_head_size as usize]);
 
 	// insert all heads to the trie
+	let mut parachain_heads = Vec::with_capacity(parachains.len());
 	let mut storage_keys = Vec::with_capacity(parachains.len());
 	let mut state_root = Default::default();
 	let mut mdb = MemoryDB::default();
@@ -62,6 +63,7 @@ where
 				.map_err(|_| "TrieMut::insert has failed")
 				.expect("TrieMut::insert should not fail in benchmarks");
 			storage_keys.push(storage_key);
+			parachain_heads.push((*parachain, parachain_head.hash()))
 		}
 	}
 	state_root = grow_trie(state_root, &mut mdb, size);
@@ -76,5 +78,5 @@ where
 	let (relay_block_number, relay_block_hash) =
 		insert_header_to_grandpa_pallet::<R, R::BridgesGrandpaPalletInstance>(state_root);
 
-	(relay_block_number, relay_block_hash, ParaHeadsProof(proof))
+	(relay_block_number, relay_block_hash, ParaHeadsProof(proof), parachain_heads)
 }
diff --git a/bridges/modules/grandpa/src/extension.rs b/bridges/modules/grandpa/src/extension.rs
index bda7d49d7c436505c6a923605198fd1f4e78b335..007c5f3df7475caf38280d1483034753674fe6c7 100644
--- a/bridges/modules/grandpa/src/extension.rs
+++ b/bridges/modules/grandpa/src/extension.rs
@@ -75,6 +75,13 @@ macro_rules! declare_bridge_reject_obsolete_grandpa_header {
 							if best_finalized_number < bundled_block_number {
 								Ok(sp_runtime::transaction_validity::ValidTransaction::default())
 							} else {
+								log::trace!(
+									target: $crate::LOG_TARGET,
+									"Rejecting obsolete bridged header: bundled {:?}, best {:?}",
+									bundled_block_number,
+									best_finalized_number,
+								);
+
 								sp_runtime::transaction_validity::InvalidTransaction::Stale.into()
 							}
 						},
diff --git a/bridges/modules/grandpa/src/lib.rs b/bridges/modules/grandpa/src/lib.rs
index c75ccc2e80a8159e9b3ae186daf0991441c971ae..4074421fc52d58c3fafa176c5bee117263c13ad0 100644
--- a/bridges/modules/grandpa/src/lib.rs
+++ b/bridges/modules/grandpa/src/lib.rs
@@ -60,7 +60,7 @@ pub use pallet::*;
 pub use weights::WeightInfo;
 
 /// The target that will be used when publishing logs related to this pallet.
-const LOG_TARGET: &str = "runtime::bridge-grandpa";
+pub const LOG_TARGET: &str = "runtime::bridge-grandpa";
 
 /// Block number of the bridged chain.
 pub type BridgedBlockNumber<T, I> = BlockNumberOf<<T as Config<I>>::BridgedChain>;
diff --git a/bridges/modules/messages/src/lib.rs b/bridges/modules/messages/src/lib.rs
index ca4b7044b2cfb19ffe9b1d72c8c654eb8a9fb67a..48ca546def8c1029c8c467b5234e87c802288240 100644
--- a/bridges/modules/messages/src/lib.rs
+++ b/bridges/modules/messages/src/lib.rs
@@ -91,7 +91,7 @@ mod mock;
 pub use pallet::*;
 
 /// The target that will be used when publishing logs related to this pallet.
-const LOG_TARGET: &str = "runtime::bridge-messages";
+pub const LOG_TARGET: &str = "runtime::bridge-messages";
 
 #[frame_support::pallet]
 pub mod pallet {
diff --git a/bridges/modules/parachains/src/benchmarking.rs b/bridges/modules/parachains/src/benchmarking.rs
index 749182a82ea690475864d528a58c2b14fcc504a5..aba296dfc1d5bd4c4b469c93520922b2dbe2ace2 100644
--- a/bridges/modules/parachains/src/benchmarking.rs
+++ b/bridges/modules/parachains/src/benchmarking.rs
@@ -21,7 +21,7 @@ use crate::{
 	RelayBlockNumber,
 };
 
-use bp_polkadot_core::parachains::{ParaHeadsProof, ParaId};
+use bp_polkadot_core::parachains::{ParaHash, ParaHeadsProof, ParaId};
 use bp_runtime::StorageProofSize;
 use frame_benchmarking::{account, benchmarks_instance_pallet};
 use frame_system::RawOrigin;
@@ -37,7 +37,7 @@ pub trait Config<I: 'static>: crate::Config<I> {
 		parachains: &[ParaId],
 		parachain_head_size: u32,
 		proof_size: StorageProofSize,
-	) -> (RelayBlockNumber, RelayBlockHash, ParaHeadsProof);
+	) -> (RelayBlockNumber, RelayBlockHash, ParaHeadsProof, Vec<(ParaId, ParaHash)>);
 }
 
 benchmarks_instance_pallet! {
@@ -57,13 +57,13 @@ benchmarks_instance_pallet! {
 
 		let sender = account("sender", 0, 0);
 		let parachains = (1..=p).map(ParaId).collect::<Vec<_>>();
-		let (relay_block_number, relay_block_hash, parachain_heads_proof) = T::prepare_parachain_heads_proof(
+		let (relay_block_number, relay_block_hash, parachain_heads_proof, parachains_heads) = T::prepare_parachain_heads_proof(
 			&parachains,
 			DEFAULT_PARACHAIN_HEAD_SIZE,
 			StorageProofSize::Minimal(0),
 		);
 		let at_relay_block = (relay_block_number, relay_block_hash);
-	}: submit_parachain_heads(RawOrigin::Signed(sender), at_relay_block, parachains.clone(), parachain_heads_proof)
+	}: submit_parachain_heads(RawOrigin::Signed(sender), at_relay_block, parachains_heads, parachain_heads_proof)
 	verify {
 		for parachain in parachains {
 			assert!(crate::Pallet::<T, I>::best_parachain_head(parachain).is_some());
@@ -74,13 +74,13 @@ benchmarks_instance_pallet! {
 	submit_parachain_heads_with_1kb_proof {
 		let sender = account("sender", 0, 0);
 		let parachains = vec![ParaId(1)];
-		let (relay_block_number, relay_block_hash, parachain_heads_proof) = T::prepare_parachain_heads_proof(
+		let (relay_block_number, relay_block_hash, parachain_heads_proof, parachains_heads) = T::prepare_parachain_heads_proof(
 			&parachains,
 			DEFAULT_PARACHAIN_HEAD_SIZE,
 			StorageProofSize::HasExtraNodes(1024),
 		);
 		let at_relay_block = (relay_block_number, relay_block_hash);
-	}: submit_parachain_heads(RawOrigin::Signed(sender), at_relay_block, parachains.clone(), parachain_heads_proof)
+	}: submit_parachain_heads(RawOrigin::Signed(sender), at_relay_block, parachains_heads, parachain_heads_proof)
 	verify {
 		for parachain in parachains {
 			assert!(crate::Pallet::<T, I>::best_parachain_head(parachain).is_some());
@@ -91,13 +91,13 @@ benchmarks_instance_pallet! {
 	submit_parachain_heads_with_16kb_proof {
 		let sender = account("sender", 0, 0);
 		let parachains = vec![ParaId(1)];
-		let (relay_block_number, relay_block_hash, parachain_heads_proof) = T::prepare_parachain_heads_proof(
+		let (relay_block_number, relay_block_hash, parachain_heads_proof, parachains_heads) = T::prepare_parachain_heads_proof(
 			&parachains,
 			DEFAULT_PARACHAIN_HEAD_SIZE,
 			StorageProofSize::HasExtraNodes(16 * 1024),
 		);
 		let at_relay_block = (relay_block_number, relay_block_hash);
-	}: submit_parachain_heads(RawOrigin::Signed(sender), at_relay_block, parachains.clone(), parachain_heads_proof)
+	}: submit_parachain_heads(RawOrigin::Signed(sender), at_relay_block, parachains_heads, parachain_heads_proof)
 	verify {
 		for parachain in parachains {
 			assert!(crate::Pallet::<T, I>::best_parachain_head(parachain).is_some());
diff --git a/bridges/modules/parachains/src/extension.rs b/bridges/modules/parachains/src/extension.rs
index 1d0eb7a7ff90810c9133ae53eba4375018057cef..efcd9ec567ff81ab5fa0d4820b2544178df4955c 100644
--- a/bridges/modules/parachains/src/extension.rs
+++ b/bridges/modules/parachains/src/extension.rs
@@ -72,15 +72,34 @@ macro_rules! declare_bridge_reject_obsolete_parachain_header {
 							ref parachains,
 							..
 						}) if parachains.len() == 1 => {
-							let parachain = parachains.get(0).expect("verified by match condition; qed");
+							let (parachain, parachain_head_hash) = parachains.get(0).expect("verified by match condition; qed");
 
 							let bundled_relay_block_number = at_relay_block.0;
 
 							let best_parachain_head = $crate::BestParaHeads::<$runtime, $instance>::get(parachain);
+
 							match best_parachain_head {
 								Some(best_parachain_head) if best_parachain_head.at_relay_block_number
-									>= bundled_relay_block_number =>
-										sp_runtime::transaction_validity::InvalidTransaction::Stale.into(),
+									>= bundled_relay_block_number => {
+									log::trace!(
+										target: $crate::LOG_TARGET,
+										"Rejecting obsolete parachain-head {:?} transaction: bundled relay block number: \
+										{:?} best relay block number: {:?}",
+										parachain,
+										bundled_relay_block_number,
+										best_parachain_head.at_relay_block_number,
+									);
+									sp_runtime::transaction_validity::InvalidTransaction::Stale.into()
+								}
+								Some(best_parachain_head) if best_parachain_head.head_hash == *parachain_head_hash => {
+									log::trace!(
+										target: $crate::LOG_TARGET,
+										"Rejecting obsolete parachain-head {:?} transaction: head hash {:?}",
+										parachain,
+										best_parachain_head.head_hash,
+									);
+									sp_runtime::transaction_validity::InvalidTransaction::Stale.into()
+								}
 								_ => Ok(sp_runtime::transaction_validity::ValidTransaction::default()),
 							}
 						},
@@ -118,7 +137,7 @@ mod tests {
 		mock::{run_test, Call, TestRuntime},
 		BestParaHead, BestParaHeads, RelayBlockNumber,
 	};
-	use bp_polkadot_core::parachains::{ParaHeadsProof, ParaId};
+	use bp_polkadot_core::parachains::{ParaHash, ParaHeadsProof, ParaId};
 	use frame_support::weights::{DispatchClass, DispatchInfo, Pays};
 	use sp_runtime::traits::SignedExtension;
 
@@ -127,7 +146,10 @@ mod tests {
 		Call::Parachains => ()
 	}
 
-	fn validate_submit_parachain_heads(num: RelayBlockNumber, parachains: Vec<ParaId>) -> bool {
+	fn validate_submit_parachain_heads(
+		num: RelayBlockNumber,
+		parachains: Vec<(ParaId, ParaHash)>,
+	) -> bool {
 		BridgeRejectObsoleteParachainHeader
 			.validate(
 				&42,
@@ -147,29 +169,39 @@ mod tests {
 			ParaId(1),
 			BestParaHead {
 				at_relay_block_number: 10,
-				head_hash: Default::default(),
+				head_hash: [1u8; 32].into(),
 				next_imported_hash_position: 0,
 			},
 		);
 	}
 
 	#[test]
-	fn extension_rejects_obsolete_header() {
+	fn extension_rejects_header_from_the_obsolete_relay_block() {
 		run_test(|| {
 			// when current best finalized is #10 and we're trying to import header#5 => tx is
 			// rejected
 			sync_to_relay_header_10();
-			assert!(!validate_submit_parachain_heads(5, vec![ParaId(1)]));
+			assert!(!validate_submit_parachain_heads(5, vec![(ParaId(1), [1u8; 32].into())]));
+		});
+	}
+
+	#[test]
+	fn extension_rejects_header_from_the_same_relay_block() {
+		run_test(|| {
+			// when current best finalized is #10 and we're trying to import header#10 => tx is
+			// rejected
+			sync_to_relay_header_10();
+			assert!(!validate_submit_parachain_heads(10, vec![(ParaId(1), [1u8; 32].into())]));
 		});
 	}
 
 	#[test]
-	fn extension_rejects_same_header() {
+	fn extension_rejects_header_from_new_relay_block_with_same_hash() {
 		run_test(|| {
 			// when current best finalized is #10 and we're trying to import header#10 => tx is
 			// rejected
 			sync_to_relay_header_10();
-			assert!(!validate_submit_parachain_heads(10, vec![ParaId(1)]));
+			assert!(!validate_submit_parachain_heads(20, vec![(ParaId(1), [1u8; 32].into())]));
 		});
 	}
 
@@ -179,7 +211,7 @@ mod tests {
 			// when current best finalized is #10 and we're trying to import header#15 => tx is
 			// accepted
 			sync_to_relay_header_10();
-			assert!(validate_submit_parachain_heads(15, vec![ParaId(1)]));
+			assert!(validate_submit_parachain_heads(15, vec![(ParaId(1), [2u8; 32].into())]));
 		});
 	}
 
@@ -189,7 +221,10 @@ mod tests {
 			// when current best finalized is #10 and we're trying to import header#5, but another
 			// parachain head is also supplied => tx is accepted
 			sync_to_relay_header_10();
-			assert!(validate_submit_parachain_heads(5, vec![ParaId(1), ParaId(2)]));
+			assert!(validate_submit_parachain_heads(
+				5,
+				vec![(ParaId(1), [1u8; 32].into()), (ParaId(2), [1u8; 32].into())]
+			));
 		});
 	}
 }
diff --git a/bridges/modules/parachains/src/lib.rs b/bridges/modules/parachains/src/lib.rs
index ec2d16427e71ae92da166f2ea7f706676639e0c2..05e14575eacf3f012a4977d25ff5bb41a7315bd2 100644
--- a/bridges/modules/parachains/src/lib.rs
+++ b/bridges/modules/parachains/src/lib.rs
@@ -49,7 +49,7 @@ mod extension;
 mod mock;
 
 /// The target that will be used when publishing logs related to this pallet.
-const LOG_TARGET: &str = "runtime::bridge-parachains";
+pub const LOG_TARGET: &str = "runtime::bridge-parachains";
 
 /// Block hash of the bridged relay chain.
 pub type RelayBlockHash = bp_polkadot_core::Hash;
@@ -209,7 +209,7 @@ pub mod pallet {
 		pub fn submit_parachain_heads(
 			_origin: OriginFor<T>,
 			at_relay_block: (RelayBlockNumber, RelayBlockHash),
-			parachains: Vec<ParaId>,
+			parachains: Vec<(ParaId, ParaHash)>,
 			parachain_heads_proof: ParaHeadsProof,
 		) -> DispatchResultWithPostInfo {
 			Self::ensure_not_halted().map_err(Error::<T, I>::BridgeModule)?;
@@ -235,7 +235,7 @@ pub mod pallet {
 				relay_block_hash,
 				sp_trie::StorageProof::new(parachain_heads_proof.0),
 				move |storage| {
-					for parachain in parachains {
+					for (parachain, parachain_head_hash) in parachains {
 						// if we're not tracking this parachain, we'll just ignore its head proof here
 						if !T::TrackedParachains::contains(&parachain) {
 							log::trace!(
@@ -272,12 +272,27 @@ pub mod pallet {
 							},
 						};
 
+						// if relayer has specified invalid parachain head hash, ignore the head
+						// (this isn't strictly necessary, but better safe than sorry)
+						let actual_parachain_head_hash = parachain_head.hash();
+						if parachain_head_hash != actual_parachain_head_hash {
+							log::trace!(
+								target: LOG_TARGET,
+								"The submitter has specified invalid parachain {:?} head hash: {:?} vs {:?}",
+								parachain,
+								parachain_head_hash,
+								actual_parachain_head_hash,
+							);
+							continue;
+						}
+
 						let prune_happened: Result<_, ()> = BestParaHeads::<T, I>::try_mutate(parachain, |stored_best_head| {
 							let artifacts = Pallet::<T, I>::update_parachain_head(
 								parachain,
 								stored_best_head.take(),
 								relay_block_number,
 								parachain_head,
+								parachain_head_hash,
 							)?;
 							*stored_best_head = Some(artifacts.best_head);
 							Ok(artifacts.prune_happened)
@@ -364,10 +379,10 @@ pub mod pallet {
 			stored_best_head: Option<BestParaHead>,
 			updated_at_relay_block_number: RelayBlockNumber,
 			updated_head: ParaHead,
+			updated_head_hash: ParaHash,
 		) -> Result<UpdateParachainHeadArtifacts, ()> {
 			// check if head has been already updated at better relay chain block. Without this
 			// check, we may import heads in random order
-			let updated_head_hash = updated_head.hash();
 			let next_imported_hash_position = match stored_best_head {
 				Some(stored_best_head)
 					if stored_best_head.at_relay_block_number <= updated_at_relay_block_number =>
@@ -376,7 +391,7 @@ pub mod pallet {
 					if updated_head_hash == stored_best_head.head_hash {
 						log::trace!(
 							target: LOG_TARGET,
-							"The head of parachain {:?} can't be updated to {}, because it has been already updated\
+							"The head of parachain {:?} can't be updated to {}, because it has been already updated \
 							to the same value at previous relay chain block: {} < {}",
 							parachain,
 							updated_head_hash,
@@ -392,7 +407,7 @@ pub mod pallet {
 				Some(stored_best_head) => {
 					log::trace!(
 						target: LOG_TARGET,
-						"The head of parachain {:?} can't be updated to {}, because it has been already updated\
+						"The head of parachain {:?} can't be updated to {}, because it has been already updated \
 						to {} at better relay chain block: {} > {}",
 						parachain,
 						updated_head_hash,
@@ -530,7 +545,8 @@ mod tests {
 
 	fn prepare_parachain_heads_proof(
 		heads: Vec<(u32, ParaHead)>,
-	) -> (RelayBlockHash, ParaHeadsProof) {
+	) -> (RelayBlockHash, ParaHeadsProof, Vec<(ParaId, ParaHash)>) {
+		let mut parachains = Vec::with_capacity(heads.len());
 		let mut root = Default::default();
 		let mut mdb = MemoryDB::default();
 		{
@@ -541,6 +557,7 @@ mod tests {
 				trie.insert(&storage_key.0, &head.encode())
 					.map_err(|_| "TrieMut::insert has failed")
 					.expect("TrieMut::insert should not fail in tests");
+				parachains.push((ParaId(parachain), head.hash()));
 			}
 		}
 
@@ -551,7 +568,7 @@ mod tests {
 			.expect("record_all_keys should not fail in benchmarks");
 		let storage_proof = proof_recorder.drain().into_iter().map(|n| n.data.to_vec()).collect();
 
-		(root, ParaHeadsProof(storage_proof))
+		(root, ParaHeadsProof(storage_proof), parachains)
 	}
 
 	fn initial_best_head(parachain: u32) -> BestParaHead {
@@ -573,12 +590,13 @@ mod tests {
 	fn import_parachain_1_head(
 		relay_chain_block: RelayBlockNumber,
 		relay_state_root: RelayBlockHash,
+		parachains: Vec<(ParaId, ParaHash)>,
 		proof: ParaHeadsProof,
 	) -> DispatchResultWithPostInfo {
 		Pallet::<TestRuntime>::submit_parachain_heads(
 			Origin::signed(1),
 			(relay_chain_block, test_relay_header(relay_chain_block, relay_state_root).hash()),
-			vec![ParaId(1)],
+			parachains,
 			proof,
 		)
 	}
@@ -595,7 +613,8 @@ mod tests {
 
 	#[test]
 	fn submit_parachain_heads_checks_operating_mode() {
-		let (state_root, proof) = prepare_parachain_heads_proof(vec![(1, head_data(1, 0))]);
+		let (state_root, proof, parachains) =
+			prepare_parachain_heads_proof(vec![(1, head_data(1, 0))]);
 
 		run_test(|| {
 			initialize(state_root);
@@ -606,7 +625,7 @@ mod tests {
 				Pallet::<TestRuntime>::submit_parachain_heads(
 					Origin::signed(1),
 					(0, test_relay_header(0, state_root).hash()),
-					vec![ParaId(1), ParaId(2), ParaId(3)],
+					parachains.clone(),
 					proof.clone(),
 				),
 				Error::<TestRuntime>::BridgeModule(OwnedBridgeModuleError::Halted)
@@ -617,7 +636,7 @@ mod tests {
 			assert_ok!(Pallet::<TestRuntime>::submit_parachain_heads(
 				Origin::signed(1),
 				(0, test_relay_header(0, state_root).hash()),
-				vec![ParaId(1)],
+				parachains,
 				proof,
 			),);
 		});
@@ -625,7 +644,7 @@ mod tests {
 
 	#[test]
 	fn imports_initial_parachain_heads() {
-		let (state_root, proof) =
+		let (state_root, proof, parachains) =
 			prepare_parachain_heads_proof(vec![(1, head_data(1, 0)), (3, head_data(3, 10))]);
 		run_test(|| {
 			initialize(state_root);
@@ -634,7 +653,7 @@ mod tests {
 			assert_ok!(Pallet::<TestRuntime>::submit_parachain_heads(
 				Origin::signed(1),
 				(0, test_relay_header(0, state_root).hash()),
-				vec![ParaId(1), ParaId(2), ParaId(3)],
+				parachains,
 				proof,
 			),);
 
@@ -667,12 +686,14 @@ mod tests {
 
 	#[test]
 	fn imports_parachain_heads_is_able_to_progress() {
-		let (state_root_5, proof_5) = prepare_parachain_heads_proof(vec![(1, head_data(1, 5))]);
-		let (state_root_10, proof_10) = prepare_parachain_heads_proof(vec![(1, head_data(1, 10))]);
+		let (state_root_5, proof_5, parachains_5) =
+			prepare_parachain_heads_proof(vec![(1, head_data(1, 5))]);
+		let (state_root_10, proof_10, parachains_10) =
+			prepare_parachain_heads_proof(vec![(1, head_data(1, 10))]);
 		run_test(|| {
 			// start with relay block #0 and import head#5 of parachain#1
 			initialize(state_root_5);
-			assert_ok!(import_parachain_1_head(0, state_root_5, proof_5));
+			assert_ok!(import_parachain_1_head(0, state_root_5, parachains_5, proof_5));
 			assert_eq!(
 				BestParaHeads::<TestRuntime>::get(ParaId(1)),
 				Some(BestParaHead {
@@ -692,7 +713,7 @@ mod tests {
 
 			// import head#10 of parachain#1 at relay block #1
 			proceed(1, state_root_10);
-			assert_ok!(import_parachain_1_head(1, state_root_10, proof_10));
+			assert_ok!(import_parachain_1_head(1, state_root_10, parachains_10, proof_10));
 			assert_eq!(
 				BestParaHeads::<TestRuntime>::get(ParaId(1)),
 				Some(BestParaHead {
@@ -714,7 +735,7 @@ mod tests {
 
 	#[test]
 	fn ignores_untracked_parachain() {
-		let (state_root, proof) = prepare_parachain_heads_proof(vec![
+		let (state_root, proof, parachains) = prepare_parachain_heads_proof(vec![
 			(1, head_data(1, 5)),
 			(UNTRACKED_PARACHAIN_ID, head_data(1, 5)),
 			(2, head_data(1, 5)),
@@ -726,7 +747,7 @@ mod tests {
 			assert_ok!(Pallet::<TestRuntime>::submit_parachain_heads(
 				Origin::signed(1),
 				(0, test_relay_header(0, state_root).hash()),
-				vec![ParaId(1), ParaId(UNTRACKED_PARACHAIN_ID), ParaId(2)],
+				parachains,
 				proof,
 			));
 			assert_eq!(
@@ -751,32 +772,35 @@ mod tests {
 
 	#[test]
 	fn does_nothing_when_already_imported_this_head_at_previous_relay_header() {
-		let (state_root, proof) = prepare_parachain_heads_proof(vec![(1, head_data(1, 0))]);
+		let (state_root, proof, parachains) =
+			prepare_parachain_heads_proof(vec![(1, head_data(1, 0))]);
 		run_test(|| {
 			// import head#0 of parachain#1 at relay block#0
 			initialize(state_root);
-			assert_ok!(import_parachain_1_head(0, state_root, proof.clone()));
+			assert_ok!(import_parachain_1_head(0, state_root, parachains.clone(), proof.clone()));
 			assert_eq!(BestParaHeads::<TestRuntime>::get(ParaId(1)), Some(initial_best_head(1)));
 
 			// try to import head#0 of parachain#1 at relay block#1
 			// => call succeeds, but nothing is changed
 			proceed(1, state_root);
-			assert_ok!(import_parachain_1_head(1, state_root, proof));
+			assert_ok!(import_parachain_1_head(1, state_root, parachains, proof));
 			assert_eq!(BestParaHeads::<TestRuntime>::get(ParaId(1)), Some(initial_best_head(1)));
 		});
 	}
 
 	#[test]
 	fn does_nothing_when_already_imported_head_at_better_relay_header() {
-		let (state_root_5, proof_5) = prepare_parachain_heads_proof(vec![(1, head_data(1, 5))]);
-		let (state_root_10, proof_10) = prepare_parachain_heads_proof(vec![(1, head_data(1, 10))]);
+		let (state_root_5, proof_5, parachains_5) =
+			prepare_parachain_heads_proof(vec![(1, head_data(1, 5))]);
+		let (state_root_10, proof_10, parachains_10) =
+			prepare_parachain_heads_proof(vec![(1, head_data(1, 10))]);
 		run_test(|| {
 			// start with relay block #0
 			initialize(state_root_5);
 
 			// head#10 of parachain#1 at relay block#1
 			proceed(1, state_root_10);
-			assert_ok!(import_parachain_1_head(1, state_root_10, proof_10));
+			assert_ok!(import_parachain_1_head(1, state_root_10, parachains_10, proof_10));
 			assert_eq!(
 				BestParaHeads::<TestRuntime>::get(ParaId(1)),
 				Some(BestParaHead {
@@ -786,9 +810,9 @@ mod tests {
 				})
 			);
 
-			// now try to import head#1 at relay block#0
+			// now try to import head#5 at relay block#0
 			// => nothing is changed, because better head has already been imported
-			assert_ok!(import_parachain_1_head(0, state_root_5, proof_5));
+			assert_ok!(import_parachain_1_head(0, state_root_5, parachains_5, proof_5));
 			assert_eq!(
 				BestParaHeads::<TestRuntime>::get(ParaId(1)),
 				Some(BestParaHead {
@@ -807,7 +831,8 @@ mod tests {
 
 			// import exactly `HeadsToKeep` headers
 			for i in 0..heads_to_keep {
-				let (state_root, proof) = prepare_parachain_heads_proof(vec![(1, head_data(1, i))]);
+				let (state_root, proof, parachains) =
+					prepare_parachain_heads_proof(vec![(1, head_data(1, i))]);
 				if i == 0 {
 					initialize(state_root);
 				} else {
@@ -815,7 +840,7 @@ mod tests {
 				}
 
 				let expected_weight = weight_of_import_parachain_1_head(&proof, false);
-				let result = import_parachain_1_head(i, state_root, proof);
+				let result = import_parachain_1_head(i, state_root, parachains, proof);
 				assert_ok!(result);
 				assert_eq!(result.expect("checked above").actual_weight, Some(expected_weight));
 			}
@@ -827,11 +852,11 @@ mod tests {
 			}
 
 			// import next relay chain header and next parachain head
-			let (state_root, proof) =
+			let (state_root, proof, parachains) =
 				prepare_parachain_heads_proof(vec![(1, head_data(1, heads_to_keep))]);
 			proceed(heads_to_keep, state_root);
 			let expected_weight = weight_of_import_parachain_1_head(&proof, true);
-			let result = import_parachain_1_head(heads_to_keep, state_root, proof);
+			let result = import_parachain_1_head(heads_to_keep, state_root, parachains, proof);
 			assert_ok!(result);
 			assert_eq!(result.expect("checked above").actual_weight, Some(expected_weight));
 
@@ -848,14 +873,15 @@ mod tests {
 
 	#[test]
 	fn fails_on_unknown_relay_chain_block() {
-		let (state_root, proof) = prepare_parachain_heads_proof(vec![(1, head_data(1, 5))]);
+		let (state_root, proof, parachains) =
+			prepare_parachain_heads_proof(vec![(1, head_data(1, 5))]);
 		run_test(|| {
 			// start with relay block #0
 			initialize(state_root);
 
 			// try to import head#5 of parachain#1 at unknown relay chain block #1
 			assert_noop!(
-				import_parachain_1_head(1, state_root, proof),
+				import_parachain_1_head(1, state_root, parachains, proof),
 				Error::<TestRuntime>::UnknownRelayChainBlock
 			);
 		});
@@ -863,14 +889,15 @@ mod tests {
 
 	#[test]
 	fn fails_on_invalid_storage_proof() {
-		let (_state_root, proof) = prepare_parachain_heads_proof(vec![(1, head_data(1, 5))]);
+		let (_state_root, proof, parachains) =
+			prepare_parachain_heads_proof(vec![(1, head_data(1, 5))]);
 		run_test(|| {
 			// start with relay block #0
 			initialize(Default::default());
 
 			// try to import head#5 of parachain#1 at relay chain block #0
 			assert_noop!(
-				import_parachain_1_head(0, Default::default(), proof),
+				import_parachain_1_head(0, Default::default(), parachains, proof),
 				Error::<TestRuntime>::InvalidStorageProof
 			);
 		});
@@ -878,15 +905,16 @@ mod tests {
 
 	#[test]
 	fn is_not_rewriting_existing_head_if_failed_to_read_updated_head() {
-		let (state_root_5, proof_5) = prepare_parachain_heads_proof(vec![(1, head_data(1, 5))]);
-		let (state_root_10_at_20, proof_10_at_20) =
+		let (state_root_5, proof_5, parachains_5) =
+			prepare_parachain_heads_proof(vec![(1, head_data(1, 5))]);
+		let (state_root_10_at_20, proof_10_at_20, parachains_10_at_20) =
 			prepare_parachain_heads_proof(vec![(2, head_data(2, 10))]);
-		let (state_root_10_at_30, proof_10_at_30) =
+		let (state_root_10_at_30, proof_10_at_30, parachains_10_at_30) =
 			prepare_parachain_heads_proof(vec![(1, head_data(1, 10))]);
 		run_test(|| {
 			// we've already imported head#5 of parachain#1 at relay block#10
 			initialize(state_root_5);
-			import_parachain_1_head(0, state_root_5, proof_5).expect("ok");
+			import_parachain_1_head(0, state_root_5, parachains_5, proof_5).expect("ok");
 			assert_eq!(
 				Pallet::<TestRuntime>::best_parachain_head(ParaId(1)),
 				Some(head_data(1, 5))
@@ -900,7 +928,7 @@ mod tests {
 			assert_ok!(Pallet::<TestRuntime>::submit_parachain_heads(
 				Origin::signed(1),
 				(20, test_relay_header(20, state_root_10_at_20).hash()),
-				vec![ParaId(1)],
+				parachains_10_at_20,
 				proof_10_at_20,
 			),);
 			assert_eq!(
@@ -916,7 +944,7 @@ mod tests {
 			assert_ok!(Pallet::<TestRuntime>::submit_parachain_heads(
 				Origin::signed(1),
 				(30, test_relay_header(30, state_root_10_at_30).hash()),
-				vec![ParaId(1)],
+				parachains_10_at_30,
 				proof_10_at_30,
 			),);
 			assert_eq!(
diff --git a/bridges/relays/lib-substrate-relay/src/parachains/mod.rs b/bridges/relays/lib-substrate-relay/src/parachains/mod.rs
index 51549ef02bd526cb6669eec6f90abd665bc67704..f201476aa163516efd9b54e02044ecf8fb672242 100644
--- a/bridges/relays/lib-substrate-relay/src/parachains/mod.rs
+++ b/bridges/relays/lib-substrate-relay/src/parachains/mod.rs
@@ -18,7 +18,7 @@
 //! parachain finality proofs synchronization pipelines.
 
 use async_trait::async_trait;
-use bp_polkadot_core::parachains::{ParaHeadsProof, ParaId};
+use bp_polkadot_core::parachains::{ParaHash, ParaHeadsProof, ParaId};
 use pallet_bridge_parachains::{
 	Call as BridgeParachainsCall, Config as BridgeParachainsConfig, RelayBlockHash,
 	RelayBlockHasher, RelayBlockNumber,
@@ -71,7 +71,7 @@ pub trait SubmitParachainHeadsCallBuilder<P: SubstrateParachainsPipeline>:
 	/// function of bridge parachains module at the target chain.
 	fn build_submit_parachain_heads_call(
 		at_relay_block: HeaderIdOf<P::SourceRelayChain>,
-		parachains: Vec<ParaId>,
+		parachains: Vec<(ParaId, ParaHash)>,
 		parachain_heads_proof: ParaHeadsProof,
 	) -> CallOf<P::TargetChain>;
 }
@@ -97,7 +97,7 @@ where
 {
 	fn build_submit_parachain_heads_call(
 		at_relay_block: HeaderIdOf<P::SourceRelayChain>,
-		parachains: Vec<ParaId>,
+		parachains: Vec<(ParaId, ParaHash)>,
 		parachain_heads_proof: ParaHeadsProof,
 	) -> CallOf<P::TargetChain> {
 		BridgeParachainsCall::<R, I>::submit_parachain_heads {
diff --git a/bridges/relays/lib-substrate-relay/src/parachains/source.rs b/bridges/relays/lib-substrate-relay/src/parachains/source.rs
index c613387e7a258498c03b3404a01a45453a3364e9..ecc940467035ee236a29dcddfb108fbf695cbb1d 100644
--- a/bridges/relays/lib-substrate-relay/src/parachains/source.rs
+++ b/bridges/relays/lib-substrate-relay/src/parachains/source.rs
@@ -160,23 +160,46 @@ where
 		&self,
 		at_block: HeaderIdOf<P::SourceRelayChain>,
 		parachains: &[ParaId],
-	) -> Result<ParaHeadsProof, Self::Error> {
-		let storage_keys = parachains
-			.iter()
-			.map(|para_id| {
-				parachain_head_storage_key_at_source(
-					P::SourceRelayChain::PARAS_PALLET_NAME,
-					*para_id,
-				)
-			})
-			.collect();
+	) -> Result<(ParaHeadsProof, Vec<ParaHash>), Self::Error> {
+		if parachains.len() != 1 || parachains[0].0 != P::SOURCE_PARACHAIN_PARA_ID {
+			return Err(SubstrateError::Custom(format!(
+				"Trying to prove unexpected parachains {:?}. Expected {:?}",
+				parachains,
+				P::SOURCE_PARACHAIN_PARA_ID,
+			)))
+		}
+
+		let parachain = parachains[0];
+		let storage_key =
+			parachain_head_storage_key_at_source(P::SourceRelayChain::PARAS_PALLET_NAME, parachain);
 		let parachain_heads_proof = self
 			.client
-			.prove_storage(storage_keys, at_block.1)
+			.prove_storage(vec![storage_key.clone()], at_block.1)
 			.await?
 			.iter_nodes()
 			.collect();
 
-		Ok(ParaHeadsProof(parachain_heads_proof))
+		// why we're reading parachain head here once again (it has already been read at the
+		// `parachain_head`)? that's because `parachain_head` sometimes returns obsolete parachain
+		// head and loop sometimes asks to prove this obsolete head and gets other (actual) head
+		// instead
+		//
+		// => since we want to provide proper hashes in our `submit_parachain_heads` call, we're
+		// rereading actual value here
+		let parachain_head = self
+			.client
+			.raw_storage_value(storage_key, Some(at_block.1))
+			.await?
+			.map(|h| ParaHead::decode(&mut &h.0[..]))
+			.transpose()?
+			.ok_or_else(|| {
+				SubstrateError::Custom(format!(
+					"Failed to read expected parachain {:?} head at {:?}",
+					parachain, at_block
+				))
+			})?;
+		let parachain_head_hash = parachain_head.hash();
+
+		Ok((ParaHeadsProof(parachain_heads_proof), vec![parachain_head_hash]))
 	}
 }
diff --git a/bridges/relays/lib-substrate-relay/src/parachains/target.rs b/bridges/relays/lib-substrate-relay/src/parachains/target.rs
index ca30629198b5bbfae0a45faf5d5ecd61436f269c..d26894b9bd3029e43d29f15ac05b01005f667a62 100644
--- a/bridges/relays/lib-substrate-relay/src/parachains/target.rs
+++ b/bridges/relays/lib-substrate-relay/src/parachains/target.rs
@@ -28,7 +28,7 @@ use bp_parachains::{
 	best_parachain_head_hash_storage_key_at_target, imported_parachain_head_storage_key_at_target,
 	BestParaHeadHash,
 };
-use bp_polkadot_core::parachains::{ParaHead, ParaHeadsProof, ParaId};
+use bp_polkadot_core::parachains::{ParaHash, ParaHead, ParaHeadsProof, ParaId};
 use codec::{Decode, Encode};
 use parachains_relay::{
 	parachains_loop::TargetClient, parachains_loop_metrics::ParachainsLoopMetrics,
@@ -166,7 +166,7 @@ where
 	async fn submit_parachain_heads_proof(
 		&self,
 		at_relay_block: HeaderIdOf<P::SourceRelayChain>,
-		updated_parachains: Vec<ParaId>,
+		updated_parachains: Vec<(ParaId, ParaHash)>,
 		proof: ParaHeadsProof,
 	) -> Result<(), Self::Error> {
 		let genesis_hash = *self.client.genesis_hash();
diff --git a/bridges/relays/parachains/src/parachains_loop.rs b/bridges/relays/parachains/src/parachains_loop.rs
index 60627d23e0b15b2ee213ed9abfd284c352505e27..795d07b8440ece84fa10e7cdbf7c7cbc640c4323 100644
--- a/bridges/relays/parachains/src/parachains_loop.rs
+++ b/bridges/relays/parachains/src/parachains_loop.rs
@@ -69,6 +69,16 @@ pub enum ParaHashAtSource {
 	Unavailable,
 }
 
+impl ParaHashAtSource {
+	/// Return parachain head hash, if available.
+	pub fn hash(&self) -> Option<&ParaHash> {
+		match *self {
+			ParaHashAtSource::Some(ref para_hash) => Some(para_hash),
+			_ => None,
+		}
+	}
+}
+
 /// Source client used in parachain heads synchronization loop.
 #[async_trait]
 pub trait SourceClient<P: ParachainsPipeline>: RelayClient {
@@ -87,11 +97,15 @@ pub trait SourceClient<P: ParachainsPipeline>: RelayClient {
 	) -> Result<ParaHashAtSource, Self::Error>;
 
 	/// Get parachain heads proof.
+	///
+	/// The number and order of entries in the resulting parachain head hashes vector must match the
+	/// number and order of parachains in the `parachains` vector. The incorrect implementation will
+	/// result in panic.
 	async fn prove_parachain_heads(
 		&self,
 		at_block: HeaderIdOf<P::SourceChain>,
 		parachains: &[ParaId],
-	) -> Result<ParaHeadsProof, Self::Error>;
+	) -> Result<(ParaHeadsProof, Vec<ParaHash>), Self::Error>;
 }
 
 /// Target client used in parachain heads synchronization loop.
@@ -121,7 +135,7 @@ pub trait TargetClient<P: ParachainsPipeline>: RelayClient {
 	async fn submit_parachain_heads_proof(
 		&self,
 		at_source_block: HeaderIdOf<P::SourceChain>,
-		updated_parachains: Vec<ParaId>,
+		updated_parachains: Vec<(ParaId, ParaHash)>,
 		proof: ParaHeadsProof,
 	) -> Result<(), Self::Error>;
 }
@@ -274,7 +288,7 @@ where
 		);
 
 		if is_update_required {
-			let heads_proofs = source_client
+			let (heads_proofs, head_hashes) = source_client
 				.prove_parachain_heads(best_finalized_relay_block, &updated_ids)
 				.await
 				.map_err(|e| {
@@ -292,10 +306,17 @@ where
 				P::SourceChain::NAME,
 				P::TargetChain::NAME,
 			);
+
+			assert_eq!(
+				head_hashes.len(),
+				updated_ids.len(),
+				"Incorrect parachains SourceClient implementation"
+			);
+
 			target_client
 				.submit_parachain_heads_proof(
 					best_finalized_relay_block,
-					updated_ids.clone(),
+					updated_ids.iter().cloned().zip(head_hashes).collect(),
 					heads_proofs,
 				)
 				.await
@@ -394,7 +415,7 @@ where
 
 			needs_update
 		})
-		.map(|((para_id, _), _)| para_id)
+		.map(|((para, _), _)| para)
 		.collect()
 }
 
@@ -666,7 +687,7 @@ mod tests {
 			&self,
 			_at_block: HeaderIdOf<TestChain>,
 			parachains: &[ParaId],
-		) -> Result<ParaHeadsProof, TestError> {
+		) -> Result<(ParaHeadsProof, Vec<ParaHash>), TestError> {
 			let mut proofs = Vec::new();
 			for para_id in parachains {
 				proofs.push(
@@ -680,7 +701,7 @@ mod tests {
 						.ok_or(TestError::MissingParachainHeadProof)?,
 				);
 			}
-			Ok(ParaHeadsProof(proofs))
+			Ok((ParaHeadsProof(proofs), vec![Default::default(); parachains.len()]))
 		}
 	}
 
@@ -709,7 +730,7 @@ mod tests {
 		async fn submit_parachain_heads_proof(
 			&self,
 			_at_source_block: HeaderIdOf<TestChain>,
-			_updated_parachains: Vec<ParaId>,
+			_updated_parachains: Vec<(ParaId, ParaHash)>,
 			_proof: ParaHeadsProof,
 		) -> Result<(), Self::Error> {
 			self.data.lock().await.target_submit_result.clone()?;