Preventing Duplicate Form Submissions in Web Applications
Duplicate form submissions manifest in two distinct scenarios requiring different mitigation strategies.
The first scenario involves operational duplicates, where a user triggers multiple identical requests before the server responds to the initial one (e.g., double-clicking a button). The second scenario involves logical duplicates, where two separate but valid requests arrive sequentially, carrying identical business data that could result in unintended duplicate transactions (e.g., transferrring funds twice within a short window).
Mitigating Operational Duplicates via Token Synchronization
To prevent rapid consecutive requests, a token-based pattern can be implemented. When a form is initialized, the server generates a unique token and stores it in the user's session:
Session["ExpectedSubmitToken"] = initialToken;
Upon receiving a submission, the server validates the provided token against the session value. If they match, the request proceeds, and a new token is generated and stored for the next interaction. If a mismatch occurs, the request is rejected as a duplicate operation.
var freshToken = Guid.NewGuid().ToString();
var storedToken = Session["ExpectedSubmitToken"]?.ToString();
if (storedToken != submittedToken)
{
Session["ExpectedSubmitToken"] = freshToken;
return new
{
Success = false,
ErrorCode = "TOKEN_MISMATCH",
Message = "Submission rejected. This may result from multiple rapid clicks or an expired session.",
NextToken = freshToken
};
}
Session["ExpectedSubmitToken"] = freshToken;
// Proceed with business logic...
Mitigating Logical Duplicates via Fingerprinting and Verification Challenges
For business data duplicates, operational token checks are insufficient since both requests are formally valid. The solution involves fingerprinting the request payload and requiring explicit user confirmation if a duplicate fingerprint is detected.
When a submission arrives, the server constructs a data fingerprint (e.g., concatenating key identifiers like recipient and sender details) and compares it against the last stored fingerprint.
var verificationStatus = ValidateTransactionUniqueness(orderData, challengeCode);
if (!verificationStatus.IsValid)
{
return new
{
Success = false,
ErrorCode = "DUPLICATE_CHALLENGE",
Message = verificationStatus.ErrorMessage,
RequiredChallengeCode = verificationStatus.NewChallengeCode
};
}
The ValidateTransactionUniqueness method evaluates the payload:
private ValidationState ValidateTransactionUniqueness(OrderPayload payload, string providedChallenge)
{
if (string.IsNullOrWhiteSpace(payload.Recipient) || string.IsNullOrWhiteSpace(payload.Sender))
{
return new ValidationState { IsValid = true, Message = "Insufficient data for fingerprinting; deferring to standard validation." };
}
var currentFingerprint = $"{payload.Recipient}:{payload.RecipientContact}:{payload.Sender}:{payload.SenderContact}";
var previousFingerprint = Session["LastTransactionFingerprint"]?.ToString();
if (currentFingerprint == previousFingerprint)
{
var requiredChallenge = GenerateRandomDigits(4);
var storedChallenge = Session["DuplicateChallengeCode"]?.ToString();
if (storedChallenge == null)
{
Session["DuplicateChallengeCode"] = requiredChallenge;
return new ValidationState
{
IsValid = false,
NewChallengeCode = requiredChallenge,
ErrorMessage = "This submission closely resembles a recent transaction. Please verify the details and enter the confirmation code to proceed."
};
}
if (storedChallenge != providedChallenge)
{
Session["DuplicateChallengeCode"] = requiredChallenge;
return new ValidationState
{
IsValid = false,
NewChallengeCode = requiredChallenge,
ErrorMessage = "Incorrect confirmation code. A new code has been generated. Please verify your transaction and enter the correct code."
};
}
// Challenge passed, clear challenge state and update fingerprint
Session["DuplicateChallengeCode"] = null;
Session["LastTransactionFingerprint"] = currentFingerprint;
return new ValidationState { IsValid = true, Message = "Duplicate authorized." };
}
// Unique transaction, update fingerprint
Session["LastTransactionFingerprint"] = currentFingerprint;
return new ValidationState { IsValid = true, Message = "Unique transaction." };
}