How to Avoid "Page Has Expired" Warnings

Published in PHP Magazine on 21 Oct 2004

Welcome to the first edition of Guru Speak, a new column that I'll be bringing to you every other month right here in PHP Magazine. The topics that I'll be writing about will vary, but one recurring topic that I want to focus on is that of providing thorough answers to frequently asked questions. This is a topic that is appropriate for PHP developers of all skill levels, and while some questions are not likely to concern experienced developers, the answers will hopefully lend new insight and provide a deeper understanding to everyone.

As one of the most active contributors to the PHP general mailing list for the last few years, I have answered many questions, and it's pretty easy to determine which topics trouble developers the most. While part of the reason for the frequency with which some questions are asked is that people don't search the archives or the Web prior, there are also some questions for which there is no sufficiently complete answer available, because no one feels the need to provide one. In other cases, there are so many RTFM responses that search results become very diluted, making the real answers hard to find.

It is my sincere hope that this column can begin to address these types of problems, and I would like to thank the fine folks at PHP Magazine for agreeing to make my answers freely available on the Web. I am very interested in feedback, so please feel free to contact me anytime - perhaps you have new insight to add to one of my answers, a correction to make, a suggestion for the column, or a question of your own.

I hope you enjoy Guru Speak.

How Can I Avoid "Page Has Expired" Warnings?

One problem with organizing questions and answers, and in fact a big reason why searching the archives can be difficult for a beginner, is that the same question can be posed in so many different ways. This is especially true when the same problem arises under different circumstances, or when differences in browser behavior can cause the same error to be reported in numerous ways.

A good example of this is the warning message declaring that a page has expired. Not only are there two different causes for this warning, but the warning message itself also varies from browser to browser, even under the same circumstances.

The warning is a result of the user utilizing the browser's history mechanism to access a previously viewed page, usually by clicking the back button or refreshing. One cause of the warning is that the page has expired from the browser's cache, so it wants to inform the user that it must request the page again, in case this is unwanted.

Luckily, you have some control over what a browser caches. In fact, so long as you don't forbid it, each page the user visits is cached. There are exceptions to this, such as when the user specifically configures the browser to prevent caching, or when the user sets the maximum cache size too small. These exceptions are of little concern, because the user either does this intentionally and understands the consequences, or the user will have an adverse experience on any site and likely realize that the problem is a local one.

PHP, by default, does not forbid caching. When you request a simple PHP script, no caching headers are returned. You can test this by using the LiveHTTPHeaders extension for Firefox and requesting a script such as the following:

  1. <?php
  2.  
  3. echo '<p>Guru Speak!</p>';
  4.  
  5. ?>

Of course, no PHP application has scripts quite this simplistic. In fact, most PHP applications use sessions, and this is where caching becomes a problem. Add session_start() to the previous example, so that it becomes the following:

  1. <?php
  2.  
  3. session_start();
  4. echo '<p>Guru Speak!</p>';
  5.  
  6. ?>

When requesting this new script, three new HTTP headers are included in the response:

  1. Expires: Thu, 19 Nov 1981 08:52:00 GMT
  2. Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
  3. Pragma: no-cache

These new headers eliminate caching, and this is how a stateful PHP application can so easily encounter "Page Has Expired" warnings.

What can be done? Before you change anything, it is best to consider the reasoning behind this behavior. When you use sessions, it is possible that you customize each page, and these customized pages might contain sensitive information. If an intermediary caches this sensitive information, it might be returned to the wrong user. Consider the series of events illustrated in Figure 1.

Figure 1:

Caching Can Potentially Cause Problems

This situation is unwanted, because it exposes Jack's profile to Jill. Luckily, the Cache-Control header gives us a way to distinguish between public caches (such as an intermediary) and private caches (such as the browser). The following header allows caching in private caches:

  1. Cache-Control: private

You can use header() to set this, but a better approach is to change the session.cache_limiter PHP configuration directive, because this also eliminates the Pragma header. If you don't have access to php.ini, you can set this in your scripts with the following:

  1. <?php
  2.  
  3. ini_set('session.cache_limiter', 'private');
  4.  
  5. ?>

By default, this is set to nocache. With it set to private, the following Cache-Control header is sent:

  1. Cache-Control: private, max-age=10800, pre-check=10800

The max-age and pre-check directives are given in seconds, and these can be used to limit the amount of time a page is cached. The session.cache_expire PHP configuration directive, given in minutes, controls these values. The default is 180 minutes (10800 seconds).

Now that you can allow your pages to be cached, there is still one other scenario that can cause a "Page Has Expired" warning. When a page in history (including the current page) is requested with the POST request method, the browser will warn the user before requesting the page again, because a POST request might perform some action. Most browsers explain this in the warning, and Firefox's warning is displayed in Figure 2.

Figure 2:

Firefox warns the user before resending a POST request.

Internet Explorer still gives a "Page Has Expired" warning but goes on to explain the situation further:

The page you requested was created using information you submitted in a form. This page is no longer available. As a security precaution, Internet Explorer does not automatically resubmit your information for you.

As a PHP developer, you might be thinking that the request method doesn't really matter to you. You can still perform the exact same actions; the only difference is that you use $_GET instead of $_POST in your programming logic. While this is true, it violates the HTTP specification. Section 9.1.1 of RFC 2616 has the following to say:

In particular, the convention has been established that the GET and HEAD methods SHOULD NOT have the significance of taking an action other than retrieval. These methods ought to be considered "safe". This allows user agents to represent other methods, such as POST, PUT and DELETE, in a special way, so that the user is made aware of the fact that a possibly unsafe action is being requested.

This is exactly why browsers treat responses to POST requests differently than responses to GET requests.

Luckily, there is an elegant solution to this problem. You can still use the POST request method in your forms, but you hide the target page from the browser's history mechanism. The trick is to have the target page (that processes the form) send a Location header and change the response code 302. This transparently redirects the user to another page. To illustrate this, consider three PHP scripts: form.php, process.php, and end.php:

form.php:

  1. <form action="process.php" method="POST">
  2.  
  3. <!-- Rest of Form -->
  4.  
  5. </form>

process.php:

  1. <?php
  2.  
  3. header('Location: http://example.org/end.php');
  4.  
  5. /* Form Processing Here */
  6.  
  7. ?>

end.php:

  1. <p>Thanks!</p>

When a user submits the form, the browser requests process.php using POST, and all of the form data is included. The response it receives is a 302 response, and the Location header indicates the URL it needs to request to get the page it seeks. After making the request for end.php, a message of thanks is displayed in the browser. If the user clicks the Back button, the form is displayed; the intermediate processing script is hidden from the browser's history.

The HTTP specification requires that the Location header indicates an absolute URL (such as http://example.org/end.php). See section 14.30 of RFC 2616 for more information.

Recap

To avoid "Page Has Expired" warnings, set session.cache_limiter to private, and make sure that any form using the POST method submits to an intermediate processing page that redirects the user to a different URL.

I hope you can now avoid these unwanted warnings, and I also hope that I have helped to uncover some of the mystery surrounding them. That's all for Guru Speak. See you next time.