WWW FAQs: How do I prevent the user from submitting a form twice?


2006-11-21: Some forms aren't meant to be submitted twice. But when the user clicks the "reload" or "refresh" button, the browser submits the same form fields again, sometimes with unpleasant results. The consequences can be as minor as an extra email to support staff, or as serious as a double charge on a credit card, also known as a "duplicate transaction." How can we prevent duplicate transactions? How can we fix the problem and prevent the form's action from being carried out twice?

There are two effective methods. The first is usually easier to code than the second. But highly sensitive applications, such as online shopping carts, should always use the second method to make sure a transaction is never doubled.

Redirecting After The Form Is Accepted

The user fills out the form and clicks "submit." Your PHP, CGI, ASP or other server-side code acts on that information and adds records to a database, or generates an email message, or what have you. And then the user clicks "refresh," causing it all to happen again. The browser might warn the user that clicking "reload" or "refresh" will re-submit form data, but users often fail to understand the consequences.

So what can we do about it? Well, let's think about what that "reload" button actually does! Clicking reload causes the browser to fetch the URL that is in the location bar again, and to submit whatever form data was passed along with it. So as soon as we've processed the form submission, let's just walk away from that "dangerous" URL by redirecting the user to a new URL! Since HTTP redirects always use the GET method, any form data that was POSTed along with the form submission will not be transmitted again. And if the user clicks the "Refresh" button, they are only refreshing the safe "landing" page that we redirected to.

Here's an example of a PHP page that sends user messages to a staff member. When the page is accessed without submitting any information, the form to be filled out is displayed. When information is submitted (because the form has been completed by the user), the page first acts on that information, and then redirects the user to itself without the form data.

However, we do pass an alldone field (by appending it to the URL in the same way a GET-method form submission would). The page knows that when the alldone field is present, it should display a thank-you message to the user. Clicking "refresh" at that point simply displays the thank-you message again. Mission accomplished.

I use PHP for this example, but the technique described above is a general one, just as valid in other server-side extension languages like ASP, Perl/CGI, ColdFusion and so on.


<?php
// The above must be the VERY FIRST LINE in the ENTIRE file.
// There must be NO blank lines, doctypes, etc. before it.

if ($_GET['alldone']) {
  allDone();
} elseif ($_POST['submit']) {
  submit();
} else {
  form();
}

function submit() {
  $self = $_SERVER['SCRIPT_NAME'];
  $data = $_POST['data'];
  mail('staff@example.com', 'Data Submission', $data);
  header("Location: $self?alldone=1");
  exit(0);
}

function allDone() {
  $self = $_SERVER['SCRIPT_NAME'];
?>
<html>
<head>
<title>Thank You For Submitting Your Data!</title>
</head>
<body>
<h1>Thank You For Submitting Your Data!</h1>
<a href="<?php echo $self?>">Click here if you wish to submit more data.</a>
</body>
</html>
<?php
}

function form() {
  $self = $_SERVER['SCRIPT_NAME'];
?>
<html>
<head>
<title>Data Submission Form</title>
</head>
<body>
Enter your data in the space below, then click Submit Data.
<p>
<form method="POST" action="<?php echo $self;?>">
<textarea name="data">
</textarea>
</p>
<p>
<input type="submit" name="submit" value="Submit Data" />
</p>
</form>
</body>
</html>
<?php
}
?>

This single PHP "page" actually generates three different responses in three different situations. When no form fields are present, it generates a form for the user to fill out. When the user's form data is present, it emails that data to staff@example.com, and then redirects back to itself with the addition of the alldone field. And when the alldone field is present, it thanks the user and stops. The user can't hit "Refresh" on a URL they never actually see.

Why This Isn't Enough For Critical Applications

The above is a great solution for preventing annoying double submissions of contact forms and the like. But is it good enough for mission-critical applications— things like credit card purchases? No way.

The problem is that the user can still click the "back" button and find their way back to the form. And once they're there, they might hit the "submit" button again. If the form's only purpose is to do something relatively harmless like submitting email, it's not a big problem. But if the form charges their credit card— it's a big problem!

The Mission-Critical Solution: Transaction IDs

So what's the answer? It's actually very simple. All we have to do is generate a unique "transaction ID" when we output the form, and make sure it is passed as a hidden field when the user submits their input.

When the transaction ID arrives, we check a database to find out if this ID has been used before for a successful purchase. If it has, we reject the transaction and refuse to charge the customer again for the same purchase.

There's one extra wrinkle: what if the user returns to the first page of the shopping cart to start an entirely new purchase and the browser has cached that page? In this situation, the same transaction ID could reappear, and a legitimate new purchase could be refused as a duplicate. We work around this by providing a first "welcome" page that invites the user to click on a button. That button submits a form that uses the POST method. This prevents all of the major browsers from attempting to load the page from the cache.

A real credit card processing application should be hosted on a secure server. But you don't need to worry about that for testing purposes.
The code below is a simple PHP/MySQL application that invites the customer to enter a credit card number and expiration date, and then sells them a widget... but refuses to do so if they back up to use the page again after a successful purchase.

Of course, this short program does not really charge a credit card. Nor does it ask for important details like the customer's billing and shipping addresses, total up the purchase price, or allow them to buy more than one product. This article is about preventing double charges, not how to build every detail of a shopping cart. So I've coded this as a simple example to keep things understandable. The technique will work just as well for a more elaborate shopping cart. If your cart involves multiple pages, just be sure to pass the transaction ID forward from page to page along with other information via hidden form fields.

If you choose to try out this sample code, you must first:

1. Change $dbUser, $dbPassword, $dbName and possibly $dbHost to the appropriate settings for your MySQL database on your web hosting account. If you have PHP, you probably also have MySQL, but check with your web host to make sure and find out your MySQL username, password and database name.

2. Create the dataids table, by accessing:


http://www.yoursite.com/foldername/myscript.php?tablecreate=1

Where www.yoursite.com should of course be your site, foldername is the web folder where you chose to put the script, and myscript.php is the name you gave to the script.

Although creating the table is not a destructive operation, this is still an administrative feature, so in a real-world system you should remove this feature from the code after you have used it once.

Once you have done these things, you can experiment with the script by accessing it on your website:


http://www.yoursite.com/foldername/myscript.php

To simulate a successful purchase, enter the credit card number 5555555555555555 and any four-digit expiration date (such as 0909). All other credit card numbers will be considered invalid.

When you see the successful purchase message, click your browser's refresh button. You will see a message refusing to charge your card again. We did it!

Now try clicking on the back button and clicking "Buy Widget" again. You won't be able to sneak in that way either! The only ways to start a new transaction are:

1. Clicking on the clearly labeled "Start A New Transaction" Button.

2. Returning to the rest of the website and following a link back to the beginning of the shopping cart script.

3. Backing up all the way to the initial "welcome to our store" page of the shopping cart script.

All three of these are clearly new shopping experiences from the user's prospective and should remove any confusion.

Again, I use PHP for this example, but the technique described above is a general one, just as valid in other server-side extension languages like ASP, Perl/CGI and ColdFusion. Similarly, I use the MySQL database, but you can use any database or even use files to keep track of the transaction IDs. Just take care to properly validate or escape anything you retrieve from a form field before using it as a filename or database parameter. Just because my code generates random integers as transaction IDs does not mean you can trust the browser not to submit sneaky stuff instead.


<?php
// The above must be the VERY FIRST LINE in the ENTIRE file.
// There must be NO blank lines, doctypes, etc. before it.

// You get these from your web host - check their
// support pages, FAQs, et cetera. If you do not
// have PHP and MySQL, find a host that offers them

$dbUser = "CHANGEME";
$dbPassword = "CHANGEME";
$dbName = "CHANGEME";

// Usually you should NOT change $dbHost, but
// do so if your web host tells you to
$dbHost = "localhost";

// You don't have to change this, but you can if you wish -
// perhaps you already have a table called dataids and
// would prefer another name for this
$dbTable = "dataids";


if ($_GET['alldone']) {
  allDone();
} elseif ($_GET['tablecreate']) {
  tableCreate();
} elseif ($_POST['submit']) {
  submit();
} elseif ($_POST['form']) {
  form();
} else {
  welcome();
}

function submit() {
  global $dbTable;
  $card = $_POST['card'];
  $expr = $_POST['expr'];
  $dataid = $_POST['dataid'];
  $self = $_SERVER['SCRIPT_NAME'];
  // Check our mysql database to see if this
  // dataid has been used before
  dbConnect();
  $found = dbFind($dbTable, "dataid", $dataid);
  $result = "";
  if (!mysql_num_rows($found)) {
    // The dataid has not been used before,
    // so we can charge the card
    
    // Store the fact that we have seen this dataid
    // in the database
    dbSet($dbTable, "dataid", $dataid);

    // A real credit card charge would take time.
    // Close the database now so that we don't
    // waste resources on the server.
    dbClose();
    $result = chargeCreditCard($card, $expr);
  } else {
    // It's been used before. Explain that
    // to the user.
    dbClose();
    $result = "NAGAIN";
  }
  $message = "";
  $title = "";
  if (substr($result, 0, 1) == "N") {
    $title = "Transaction Not Completed";
    if ($result == "NAGAIN") {
      $message = <<<EOM
<p>
<b>Duplicate Transaction</b>
</p>
You have already purchased a widget. As a
safeguard against accidental double charges, we do not
allow new purchases to be made by clicking the Back button. We apologize
for any inconvenience.
EOM
;
    } else {
      $message = <<<EOM
<p>
Credit Card Processing Error. Your bank gave us the following
response:
</p>
<p>
<b>$result</b>
</p>
<p>
We do not have any further information about this error. Contact your
financial institution if you have questions.
EOM
;
    }  
  } else {
    $title = "Purchase Complete";
    $message = <<<EOM
<p>
<b>Purchase Complete</b>
</p>
<p>
We have received your order and charged your
credit card. Your widget will be delivered shortly.
</p>
<p>
The approval code from your bank was:
</p>
<p>
$result
</p>
EOM
;
  }    
  ?>
<html>
<head>
<title><?php echo $title?></title>
</head>
<body>
<?php echo $message?>
<p>
<!-- Use a POST method form to go back and start a new
  purchase. Browsers do not cache responses to POST
  form submissions, so the user won't get stuck with
  the same dataid again. -->
<form method="POST" action="<?php echo $self?>">
<input type="submit" name="form" value="Start A New Transaction">
</form>
</p>
</body>
</html>
<?php
  exit(0);
}

function tableCreate()
{
  global $dbTable;
  $self = $_SERVER['SCRIPT_NAME'];
  dbConnect();
  dbTableCreate();
?>
<html>
<head>
<title>Table Created</title>
</head>
<body>
<h1>Table Created</h1>
<p>
The <?php echo $dbTable ?> table has been created, if it did not already exist.
</p>
<p>
<a href="<?php echo $self?>">Now click here to try out the form.</a>
</p>
</body>
</html>
<?php
}

function dbConnect()
{
  global $dbHost, $dbUser, $dbPassword, $dbName;
  if (!mysql_connect($dbHost, $dbUser, $dbPassword)) {
    die("connect failed: database server not available.");
  }
  if (!mysql_select_db($dbName)) {
    die("select failed: database is not available or " .
      "credentials are incorrect.");
  }
}

function dbTableCreate()
{
  global $dbTable;
  $query = "CREATE TABLE IF NOT EXISTS $dbTable ( " .
    "dataid NUMERIC, " .
    "PRIMARY KEY(dataid))";
  if (!mysql_query($query)) {
    die("Unable to create $dbTable in database.");
  }
}

function dbFind($table, $field, $value)
{
  $result = mysql_query(
    "SELECT $field FROM $table WHERE $field = \"" .
      mysql_real_escape_string($value) . "\"");
  if (!$result) {
    die("Unable to query database. Create the table first. " .
      "See the documentation for details.");
  }
  return $result;
}

function dbSet($table, $field, $value)
{
  $result = mysql_query(
    "INSERT INTO $table($field) " .
    "VALUES(" .
    "\"" . mysql_real_escape_string($value) . "\")");
  if (!$result) {
    die("Database INSERT failed");
  }
}

function dbClose()
{
  mysql_close();
}

function chargeCreditCard($card, $expr)
{
  // In a real shopping cart application on a secure
  // server, this would be the place to charge the
  // customer's credit card, for instance via
  // the authorize.net API. But this article is
  // about preventing double charges, not how to
  // charge credit cards.

  if ($card == "5555555555555555") {
    return "YAPPROVED";
  } else {
    return "NBAD";
  }
}

function welcome() {
  $self = $_SERVER['SCRIPT_NAME'];
?>
<html>
<head>
<title>Welcome to the Widget Store</title>
</head>
<body>
<h1>Welcome to the Widget Store</h1>
<p>
Welcome to the Widget Store! Our online store will help you
buy exactly the right widget for your needs. To get started,
just click the button below.
</p>
<p>
<form method="POST" action="<?php echo $self?>">
<input type="submit" name="form" value="Shop For Widgets"/>
</form>
</p>
</body>
</html>
<?php
}

function form() {
  $self = $_SERVER['SCRIPT_NAME'];
  // Since PHP 4.2.0 srand() has not
  // been necessary
  $dataid = rand();
?>
<html>
<head>
<title>Buy a Widget</title>
</head>
<body>
<h1>Buy a Widget</h1>
Enter your credit card and four-digit expiration date in the space below.
Then, click Buy Widget.
<form method="POST" action="<?php echo $self?>">
<p>
<input name="card" size="16" maxlength="16"> Card
</p>
<p>
<input name="expr" size="4" maxlength="4"> Expiration Date (MMYY)
</p>
<input type="hidden" name="dataid" value="<?php echo $dataid?>">
<p>
<input type="submit" name="submit" value="Buy Widget" />
</p>
</form>
</body>
</html>
<?php
}
?>

Won't The Database Keep Growing?

One apparent problem with the above code is that transaction IDs are never deleted from the system. In a real-world system, this is usually not a problem, because you'll want to keep sales records and store more information than just the transaction ID. However, if you really wanted to, you could add a timestamp field to the table and periodically delete table rows with timestamps older than a certain date.

When You Want To Stop Intentional "Duplicate Transactions" Too

For some specialized applications, the definition of a "duplicate transaction" is a little different. You're not just trying to stop the user from accidentally buying twice— you want to stop them from buying twice even if they want to. In this situation, transaction IDs aren't good enough, because the user can make a deliberate choice to back to the beginning of the entire shopping process and buy another widget.

If you really want to forbid the same person from buying two widgets, there's no guaranteed solution— a determined user could use different shipping addresses, credit card numbers and computers for the two transactions. But you can make it more difficult by retaining the order details in your database. Then, when the same user comes back with the same billing address, shipping address and items to be purchased, you can look in your database to determine that an order has already been placed by this person. If you add a time stamp feature to your order database, you'll have the option of preventing the same user from making a purchase twice in a particular period of time. All of these things are easily done with MySQL.

Preventing The Same Card From Being Used Twice

A related issue: sometimes the same credit card is used for many transactions in a row. In this situation you'll probably want to start refusing more transactions on that card. You can do this by keeping a recentcards table in MySQL, and checking this table to see if the card number already appears there. You could also include a second field tracking how many times the card has been used. You would also be well-advised to include a time stamp field and periodically delete all records that have not been updated in a certain amount of time. This reduces the dangers of keeping credit card numbers in your database. Also consider discarding every other digit of the credit card number. Two valid numbers are unlikely to share half their digits in common... especially during the period during which you keep the numbers in your database.

Coding this is very similar to my transaction ID code above. In a sense, you are using the credit card number as a transaction ID.

Legal Note: yes, you may use sample HTML, Javascript, PHP and other code presented above in your own projects. You may not reproduce large portions of the text of the article without our express permission.

Got a LiveJournal account? Keep up with the latest articles in this FAQ by adding our syndicated feed to your friends list!