Randomness is a fundamental building block for many blockchain applications - from NFT minting and gaming outcomes to lottery distributions and fair participant selection. Yet generating truly unpredictable, tamper-proof random numbers on a deterministic virtual machine like the EVM is one of the hardest problems in smart contract development.
Chainlink VRF (Verifiable Random Function) is the industry-standard solution for this problem. It provides cryptographically proven random values that cannot be manipulated by oracle operators, miners, validators, or users. But integrating VRF correctly is not trivial. Misusing it can open your protocol to a range of attacks that undermine the very fairness guarantees VRF is supposed to provide.
In this article, we examine the critical security considerations that developers must account for when integrating Chainlink VRF, drawing from real-world audit findings and the official Chainlink security guidelines.
How Chainlink VRF Works
Before diving into the security considerations, it helps to understand the mechanism at a high level. Chainlink VRF generates randomness by combining block data that is still unknown when the request is made with the oracle node's pre-committed private key. This produces both a random number and a cryptographic proof of correctness.
The process follows four steps:
- Request: Your smart contract calls
requestRandomWords(), which emits an event picked up by the Chainlink network. At this point, the block data that will seed the random value does not yet exist. - Generate: A Chainlink oracle node uses its secret key and the future block data to compute a random output along with a cryptographic proof.
- Verify: The proof is verified on-chain by the VRF Coordinator contract. If the proof is invalid - meaning the oracle attempted to manipulate the result - the transaction reverts.
- Fulfill: Once verified, the random value is delivered to your contract via the
fulfillRandomWords()callback.
The cryptographic proof is the key security property. Even if an oracle node is compromised, it cannot supply biased or manipulated random values - the on-chain proof verification would fail. The worst an adversarial node can do is withhold a response entirely, which is immediately visible on-chain and triggers economic penalties through Chainlink's staking mechanisms.
Request Ordering and Fulfillment Manipulation
If your contract can have multiple VRF requests in flight simultaneously, you must ensure that the order in which fulfillments arrive cannot be used to manipulate contract behavior.
Blockchain miners and validators have the ability to control the order in which transactions appear within a block. This means that if your contract submits requests A, B, and C, the fulfillments may arrive in any order - C, A, B for instance - and the validator gets to choose which arrangement appears on-chain.
Use requestId to Match Requests
The fundamental defense is to use the requestId returned by requestRandomWords() to match each randomness result with its original request. Never rely on the assumption that fulfillments will arrive in the same order as requests.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {VRFConsumerBaseV2Plus} from "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";
contract SecureVRFConsumer is VRFConsumerBaseV2Plus {
// Map each requestId to the user who initiated it
mapping(uint256 => address) public requestToUser;
mapping(address => uint256) public userResults;
uint256 private s_subscriptionId;
bytes32 private s_keyHash;
uint32 private s_callbackGasLimit;
uint16 private s_requestConfirmations;
constructor(
address vrfCoordinator,
uint256 subscriptionId,
bytes32 keyHash
) VRFConsumerBaseV2Plus(vrfCoordinator) {
s_subscriptionId = subscriptionId;
s_keyHash = keyHash;
s_callbackGasLimit = 100000;
s_requestConfirmations = 3;
}
function requestRandom() external returns (uint256 requestId) {
requestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: s_keyHash,
subId: s_subscriptionId,
requestConfirmations: s_requestConfirmations,
callbackGasLimit: s_callbackGasLimit,
numWords: 1,
extraArgs: VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
)
})
);
// Critical: bind the request to the caller
requestToUser[requestId] = msg.sender;
}
function fulfillRandomWords(
uint256 requestId,
uint256[] calldata randomWords
) internal override {
// Use requestId to find the correct user
address user = requestToUser[requestId];
require(user != address(0), "Unknown request");
userResults[user] = randomWords[0];
}
}
Without this pattern, an attacker who can influence transaction ordering could potentially assign a favorable random result to themselves by manipulating which fulfillment maps to which user.
Block Confirmation Time
Miners and validators on your underlying blockchain could theoretically rewrite the chain's recent history to place a randomness request into a different block. Because the VRF output depends on block data, a different block produces a different random value.
It is important to understand what this attack does and does not allow. A validator who rewrites history cannot predict what the new random value will be. They can only "re-roll the dice" - getting a fresh random output that may or may not be to their advantage. By analogy, they can force a new coin flip, but they cannot choose which side it lands on.
The requestConfirmations parameter controls how many blocks the VRF service waits before writing a fulfillment to the chain. A higher value means the request is buried deeper in the chain before the oracle responds, making rewrite attacks exponentially more expensive.
Choose your confirmation time based on the value at risk. A low-stakes gaming dApp might be fine with 3 confirmations, while a high-value lottery distributing millions of dollars should use a significantly higher value. The cost of a chain reorganization must exceed the potential profit from manipulating your contract's outcome.
Do Not Re-request or Cancel Randomness
Any re-request or cancellation of randomness is an incorrect use of VRF. This is one of the most critical security rules, yet it is frequently violated by developers who want to add "retry" logic to their contracts.
The problem is straightforward: if a party can discard an unfavorable random result and request a new one, the randomness is no longer fair. An attacker could repeatedly cancel and re-request until they receive a value that benefits them, effectively turning "random" into "attacker-selected."
dApps that implement the ability to cancel or re-request randomness for specific commitments must carefully consider the additional attack vectors this creates. In most cases, the correct design is to make randomness requests final and irrevocable once submitted.
If you need to handle the scenario where a VRF response never arrives (due to an oracle being offline, for example), the solution should involve timeout-based fallback mechanisms that do not allow the requesting party to influence the replacement random value. Chainlink's subscription model and economic incentives make non-delivery extremely unlikely, but defensive programming demands a plan for every edge case.
Lock User Inputs Before Requesting Randomness
Whenever an outcome in your contract depends on both user-supplied inputs and randomness, the contract must not accept any additional user inputs after the randomness request has been issued.
Consider a betting contract where users place bets and then randomness determines the winner. If users can modify their bets after the VRF request is in flight, an attacker who can observe pending transactions (or worse, rewrite recent chain history) could adjust their bet to align with the incoming random value.
The correct pattern is:
- Commit phase: Record all user actions that will affect the outcome (bets, choices, selections)
- Request phase: Call
requestRandomWords()and lock further input - Fulfill phase: Process the random result against the committed inputs in
fulfillRandomWords()
contract FairLottery is VRFConsumerBaseV2Plus {
enum Phase { ACCEPTING_ENTRIES, DRAWING, COMPLETE }
Phase public currentPhase;
address[] public participants;
address public winner;
uint256 private s_requestId;
function enter() external payable {
// Only accept entries during the commit phase
require(currentPhase == Phase.ACCEPTING_ENTRIES, "Entries closed");
require(msg.value == 0.1 ether, "Exact entry fee required");
participants.push(msg.sender);
}
function drawWinner() external {
require(currentPhase == Phase.ACCEPTING_ENTRIES, "Already drawing");
require(participants.length > 0, "No participants");
// Lock entries - no more participants allowed
currentPhase = Phase.DRAWING;
// Now request randomness
s_requestId = s_vrfCoordinator.requestRandomWords(/* ... */);
}
function fulfillRandomWords(
uint256 requestId,
uint256[] calldata randomWords
) internal override {
require(requestId == s_requestId, "Wrong request");
// Select winner from committed participants
uint256 index = randomWords[0] % participants.length;
winner = participants[index];
currentPhase = Phase.COMPLETE;
}
}
This pattern ensures that the set of participants is frozen before randomness enters the picture. No one can front-run the VRF response to add, remove, or modify entries.
Preventing fulfillRandomWords from Reverting
This is arguably the most commonly violated VRF security rule, and it can have severe consequences.
If your fulfillRandomWords() implementation reverts, the VRF service will not attempt to call it a second time. The random value is effectively lost. Your contract will be stuck waiting for a fulfillment that will never come, potentially locking user funds or leaving the protocol in an inconsistent state.
The recommended strategy is to keep the callback as simple as possible: store the randomness and handle complex follow-on actions in separate contract calls.
contract SafeVRFConsumer is VRFConsumerBaseV2Plus {
mapping(uint256 => uint256) public storedRandomness;
mapping(uint256 => bool) public fulfilled;
// Keep this callback minimal - just store the value
function fulfillRandomWords(
uint256 requestId,
uint256[] calldata randomWords
) internal override {
// Simple storage - cannot revert
storedRandomness[requestId] = randomWords[0];
fulfilled[requestId] = true;
}
// Complex logic happens in a separate call
function processResult(uint256 requestId) external {
require(fulfilled[requestId], "Not yet fulfilled");
uint256 randomValue = storedRandomness[requestId];
// All complex logic, external calls, and
// state transitions happen here - safe to revert
_distributeRewards(randomValue);
_updateLeaderboard(randomValue);
}
}
Avoid making external calls, performing complex calculations, or executing unbounded loops inside fulfillRandomWords(). Any of these can cause the callback to exceed the gas limit or revert, permanently losing the random value. Also ensure the callbackGasLimit parameter you pass to requestRandomWords() is high enough for your callback logic to execute.
Use VRFConsumerBaseV2Plus
When implementing the subscription method, always inherit from the official VRFConsumerBaseV2Plus contract. This base contract provides a critical validation layer:
- It includes a check that confirms randomness fulfillments are coming from the legitimate
VRFCoordinatorV2_5contract, not from an arbitrary caller - It exposes the
fulfillRandomWords()function as an internal override that only the coordinator can trigger throughrawFulfillRandomWords() - It prevents you from accidentally overriding the
rawFulfillRandomWords()function, which would break the security model
// Correct: inherit from the official base contract
contract MyContract is VRFConsumerBaseV2Plus {
constructor(address vrfCoordinator)
VRFConsumerBaseV2Plus(vrfCoordinator) {}
function fulfillRandomWords(
uint256 requestId,
uint256[] calldata randomWords
) internal override {
// Your logic here
}
// NEVER override rawFulfillRandomWords
// The base contract handles validation
}
Attempting to build your own fulfillment validation from scratch is error-prone and unnecessary. The base contract has been audited extensively and handles edge cases that are easy to miss in a custom implementation.
Maintain Adequate Subscription Funding
This is a practical operational concern that has significant security implications. Your VRF subscription must maintain a balance well above the minimum required for a single request.
If your subscription balance approaches the minimum threshold while multiple consumer contracts are making concurrent requests, fulfillments will be delayed. The oracle cannot process a request if the subscription lacks sufficient funds to cover the callback gas cost. This creates a denial-of-service condition where your protocol stops receiving random values precisely when it needs them most - for example, during a high-activity period.
Best practices for subscription management:
- Monitor balance proactively: Set up alerts when your subscription balance drops below a comfortable margin (e.g., enough for at least 100 requests)
- Automate top-ups: Use a keeper or cron job to refill the subscription when it crosses a threshold
- Account for gas spikes: During network congestion, callback gas costs increase. Size your balance to handle elevated gas prices
- Separate high-value consumers: If one consumer contract has critical, time-sensitive randomness needs, give it its own dedicated subscription rather than sharing with lower-priority consumers
Avoid ERC-4337 Account-Abstracted Wallets for Subscription Management
ERC-4337 introduces account abstraction, where user operations are pre-signed and can be executed by any bundler until they expire. This creates a subtle but important risk when combined with VRF subscriptions.
If a pre-signed UserOperation that modifies your VRF subscription (adding or removing consumers, adjusting settings) is executed by a bundler during a VRF fulfillment callback, the subscription state may change in unexpected ways. The UserOperation might effectively no-op if executed at the wrong time, delaying subscription management changes.
For VRF subscription administration, use standard externally owned accounts (EOAs) or carefully designed multisig wallets that do not rely on the ERC-4337 bundler mechanism.
Common Mistakes in the Wild
Through our audit practice at Zokyo, we have encountered several recurring patterns of VRF misuse. Here are the most common mistakes and how to avoid them.
Using blockhash as a Randomness Source
Some developers attempt to generate "randomness" using block.timestamp, blockhash(), block.difficulty, or combinations thereof. These values are either predictable or directly controlled by validators and should never be used as a source of randomness.
// INSECURE - DO NOT USE
function unsafeRandom() public view returns (uint256) {
// Validators control block.timestamp and can influence blockhash
return uint256(keccak256(abi.encodePacked(
block.timestamp,
blockhash(block.number - 1),
msg.sender
)));
}
// SECURE - Use Chainlink VRF instead
// The random value comes from a verifiable, tamper-proof oracle
After Ethereum's transition to Proof of Stake, block.difficulty was replaced by block.prevrandao. While prevrandao has better randomness properties than the old difficulty value, it is still biasable by validators and should not be used as the sole source of randomness in high-stakes applications.
Setting callbackGasLimit Too Low
If the callbackGasLimit you specify when making the VRF request is lower than the gas your fulfillRandomWords() callback actually needs, the callback will revert due to an out-of-gas error. Since the VRF service does not retry, the random value is permanently lost.
Always test your callback with realistic data and add a generous buffer. Gas costs can vary depending on the EVM state at execution time, so a callback that costs 80,000 gas in testing might need 100,000 gas or more in production.
Ignoring the requestId
Developers sometimes treat fulfillRandomWords() as if it will always correspond to their most recent request. In contracts that make multiple concurrent requests, this assumption is incorrect. Always use the requestId parameter to determine which request is being fulfilled and route the result accordingly.
Putting Complex Logic in the Callback
We regularly see contracts that perform token transfers, NFT minting, state machine transitions, and external contract calls all inside fulfillRandomWords(). Each of these operations can fail, and any failure means the entire callback reverts. As discussed earlier, the correct approach is to store the random value and handle complex operations in a separate transaction.
Integration Security Checklist
Use this checklist when reviewing any VRF integration, whether during development or as part of a security audit:
- requestId mapping: Every
requestRandomWords()call stores the returnedrequestIdand maps it to the relevant context (user, game round, etc.) - No re-requesting: The contract has no mechanism to cancel a pending VRF request and issue a new one for the same commitment
- Input lockdown: All user inputs that affect the outcome are finalized before calling
requestRandomWords() - Minimal callback:
fulfillRandomWords()stores the random value and emits an event, with complex logic handled separately - No revert risk: The callback contains no
require()statements that could fail, no external calls, and no unbounded loops - Adequate gas limit:
callbackGasLimitis set high enough for the callback to execute with a comfortable margin - Proper inheritance: The contract inherits from
VRFConsumerBaseV2Plusand does not overriderawFulfillRandomWords() - Confirmation time:
requestConfirmationsis set appropriately for the value at risk - Subscription funded: The VRF subscription has sufficient balance for expected request volume, including during gas price spikes
- No on-chain randomness: The contract does not use
blockhash,block.timestamp, orprevrandaoas randomness sources
Conclusion
Chainlink VRF provides a robust, cryptographically proven source of on-chain randomness. But like any security primitive, its guarantees only hold when it is integrated correctly. The VRF proof ensures that oracle nodes cannot manipulate the random output - but it does not protect against developer misuse, front-running around the request lifecycle, or operational failures like underfunded subscriptions.
The security considerations outlined in this article are not theoretical. They reflect real vulnerabilities found in production contracts and audit engagements. By matching requests via requestId, locking inputs before requesting randomness, keeping callbacks minimal and non-reverting, and choosing appropriate confirmation times, you can build applications that deliver on the fairness promise that VRF was designed to provide.
For protocols where randomness directly determines the distribution of significant value, we recommend a dedicated security audit that specifically examines the VRF integration alongside broader contract security. The interaction between VRF and your application's state machine often contains the most subtle and consequential vulnerabilities.