It's impossible to safely grant rewards for gamepasses bought in-game

As a Roblox developer, it is currently impossible to safely grant rewards to a player for a gamepass they have just purchased in-game.

Historically, developers have used PromptGamePassPurchaseFinished to detect in-game pass purchases and grant rewards. However, it has been revealed that clients may be able to spoof this event and theoretically unlock every gamepass in the game for free, making this event unsafe to rely upon.

Ideally this could be fixed with a simple safety check with UserOwnsGamePassAsync, but the documentation explains that due to its caching behavior, it would not provide a reliable result:

If the pass is purchased in-experience through PromptGamePassPurchase(), this function may return false due to the caching behavior.

One potential solution to this problem would be to clear the cached value immediately before firing PromptGamePassPurchase, allowing the developer to reliably check for ownership.

If Roblox is able to address this issue, it would improve my development experience because I would be able to let my players buy gamepasses in-game and enjoy the rewards without having to leave and join a different server.

32 Likes

EDIT:

Below post was based on misinterpretations of Roblox’ inner workings. The only risk PromptGamePassPurchaseFinished has is being able to re-trigger the signal. Read more


:warning: Original post:

I second this.

Currently, some creators rely on this event to grant perks, and have suffered from exploiters as they were unaware of the security risk. The official documentation even supports this use case.

I’m aware we shouldn’t discuss implementation, but I would imagine a server-side check (non-cached call if WasPurchased==true) for PromptGamePassPurchase is a good solution for this problem.

Affected methods:

  • SignalPromptProductPurchaseFinished
  • SignalPromptGamePassPurchaseFinished
  • SignalPromptBundlePurchaseFinished
  • SignalPromptSubscriptionPurchaseFinished
  • SignalPromptPurchaseFinished
5 Likes

We were told back in August of last year that they are working on improving the purchase completed events, and then told they were still working on it in December:

This was to address a separate issue where purchases were not processed at the time of purchase, but rather when the client pressed “OK” which can be attributed to this issue as well.

@SplootingCorgi Are there any updates you can provide on this? With these highly severe vulnerabilities being exposed just now and the amount of developers that rely on these events to handle critical purchases, it’s needed now more than ever.

Related thread that I think should be mentioned here on behalf of @xChris_vC:

2 Likes

I’m assuming this applies server-side, so if I were to listen to this event on the server, it could be called by exploiter with wasPurchased set to true, essentially enabling them to gain access to every paid item in the game.

Basically equivalent of this would look something like this:

-- server.lua
remote.OnServerEvent:Connect(function(player, gamePassId, wasPurchased)
  if wasPurchased then
    print(`Granted {player.UserId} perks for {gamePassId}`)
  end
end)
-- client.lua
remote:FireServer(000000000, true)

If I am understanding this correctly, this is insane vulnerability that affects some of the games that I worked for, because there is no mention anywhere that it relies on client provided values, and one would expect that it would be invoked by trusted Roblox server, or at least valided on the server.

3 Likes

Hey!

I might have a potential solution to this problem as well as UserOwnsGamePassAsync

You might want to use HttpService to fetch data from the inventory.roblox.com/…

I am pretty sure that the API does not cache the data unlike UserOwnsGamePassAsync, however, be aware of rate limitation though. I believe this might be the solution which is effective for reliably checking if the user owns the gamepass, but might be ineffective overall due to the use of the API to do so.

If you are rich, you might well host your own web server, create your own service which will fetch data from Roblox APIs (and use proxies to avoid rate limit). From there, you can use your own web server from the game to check if the user owns the gamepass.

1 Like

This is a good solution which I think(or hope?) most of those in this thread are already aware of. However, I think the goal of this feature request is to fix the vulnerability itself because the common idea here is that the purchase finished signals return secure and correct information that does not need re-validation, and thousands of developers have already implemented this logic in their game without the extra required checks, being unaware of the sheer insecurity of the system.

2 Likes

I agree with you!

The solution I mentioned should only be used as temporary workaround until the issue is addressed (given that Roblox takes really long time to fix issues). :+1:

2 Likes

The issue you’re presenting here is not quite what you think it is. Users cannot spoof their purchased state if they do not own the gamepass.

They can, however, fire the listed PromptGamePassPurchaseFinished event multiple times with successful arguments for a gamepass they already own. There’s no restriction on this, it’s been known about for a very long time, and it’s why this notice is provided within its documentation:

To avoid duplicate item grants, store the purchase in a data store. If multiple attempts are made, check if the item has been already granted. For repeatable purchases use developer products.

As long as your purchase logic checks to see if the user’s already received their perks, you’re good to go. Gamepasses shouldn’t be set up with multiple purchase logic in mind, you should use developer products for that.

5 Likes

This workaround cannot feasibly work for everyone. Please fix the security issues and don’t make us rely on HttpService.

(No shame to the person who posted the workaround! I only added my two cents because the lurking system engineers may take this as a solution)

5 Likes

This is negatively affecting my game, Clip It. We don’t have time to set up a proxy solution & people are faking large donations to get clout.

3 Likes

Building onto @Mubinets 's response, I used

Roproxy as a free Roblox proxy and created this functional system:

When you make a gamepass purchase, it will check the proxy to make sure you own it fully. When it verifies you do, it’ll call the ‘onPurchased’ function with ‘player’ and ‘gamepassId’. Due to the nature of it, it will not work in studio, however I have tested it and verified it does work in live games. I tested this with gamepasses that cost 1 robux that I did not own prior.

I implemented a method that will not attempt to reach out to the website if the same gamepass is somehow purchased twice in-game, which should not be possible without the use of exploits. That was in light of trying to prevent them from overloading the web request limit, however, this could be abused if they discover this code and it will likely need some form of rate-limiting if you are to counteract that problem specifically.

If you have any problems with this, you can always reach out to me, I do have a lot of experience with proxies and setting up web servers.

local onPurchased = function(player,gamepassId)
	print(player,gamepassId)
end

-- functions:

local marketplaceService = game:GetService("MarketplaceService")
local httpService = game:GetService("HttpService")
local lastPurchases = {}

local apiUrl = "https://inventory.roproxy.com/v1/users/%s/items/1/%s/is-owned"

local request = function(url)
	local success,response = pcall(function()
		return httpService:RequestAsync({
			["Url"] = url,
			["Method"] = "GET"
		})
	end)
	if success then
		return true,response
	else
		return false,response
	end
end

local try = function(url,attempts)
	local attempt,success,response = 0,false,nil
	while(attempt <= attempts and not success) do
		attempt += 1
		success,response = request(url)
	end
	if success and response then
		local contentType = response.Headers["content-type"]
		if contentType:find("json") then
			response.Body = httpService:JSONDecode(response.Body)
		end
	end
	return success,response
end

marketplaceService.PromptGamePassPurchaseFinished:Connect(function(player,gamepassId,wasPurchased)
	if wasPurchased then
		lastPurchases[player] = lastPurchases[player] or {}
		if not lastPurchases[player][gamepassId] then
			lastPurchases[player][gamepassId] = tick()
			local success,response = try(apiUrl:format(player.UserId,gamepassId),5)
			if success and response and response.StatusCode == 200 then
				local ownsGamepass = response.Body
				if ownsGamepass then
					onPurchased(player,gamepassId)
				end
			end
		end
	end
end)

However, this is quite a bit larger than the 4 lines of code Roblox could provide us in one method, and I wish it wasn’t this nuanced. So I also do agree to the annoyance. I provided this solution because Roblox themselves have not replied to this thread and I’m not sure that they will.

1 Like

:wave: The inventory-checking code using RoProxy isn’t actually necessary because as (vaguely) stated here, Roblox does do a server-sided backend check of the gamepass ID you’re firing to PromptGamePassPurchaseFinished to ensure you actually own it before firing the event for it:

The only issue is it can be fired multiple times by exploiters who own it. I’ve actually verified this behavior myself and confirmed it through talks with an engineer.

2 Likes

Oh, I see. Thank you for bringing this to my attention, I should likely pay better attention. I can’t imagine any easy fix for that for the engineers unless this can be completely removed from client interaction, given that if you remove their ability to fire it, you’d lose the whole function.

1 Like