Protecting A Page From Being Directly Accessed With PHP

I was thinking recently about the number of ways in which I could restrict access to a page using PHP.

The obvious option is to create a user authentication system, but in some situations that is overkill for what is required. If you just want to prevent users from going directly to a certain page then there are a few options open to you.

In this article we will look at how to protect a page from being directly accessed without using a user authentication system. I will address any pros and cons of each method so if you are looking for a way to protect a page then one of these might be useful to you.

In all of the examples here we are assuming a "source" page where the user has full access, and a "protected" page that can only be accessed by first visiting the source page. To keep the examples short we are just stopping execution of the page, rather than showing the user a nice error page.

All of the code here can be seen in the example repository that accompanies this article. The repository shows examples of all of these items working, and also contains much more fleshed out access denied messages. 

Referrer

The simplest thing we can do is look for the referrer value in the server details after the user has clicked on a link to our protected page.

When a user clicks on a link on a page they will be taken to the next page, this is normal, but also included in that request will be a header called "Referer" that details where the user came from before they clicked the link. As a side note, the spelling here is incorrect but was written into the early HTTP/1.0 protocol RFC (RFC1945) and was never changed.

This variable can be accessed in PHP in two ways, either through the getallheaders() function, which will contain the header we are looking for.

$referrer = getallheaders()['Referer'];

Or, via the $_SERVER superglobal, which is used by PHP to store the same header to make it more readily available.

$referrer = $_SERVER['HTTP_REFERER'];

To protect the page in question we need to define the page we want to detect the user coming from, and then make sure that that page matches the value in the Referer header.

// Location of page that the user must have visited first.
$allowed_referrer = '/referrer/';

if (!isset($_SERVER['HTTP_REFERER']) || !str_contains($_SERVER['HTTP_REFERER'], $allowed_referrer)) {
  // The user didn't come from the correct place, so deny access.
  header('HTTP/1.0 401 Unauthorized');
  die('Invalid referrer.');
}

Whilst this is the simplest thing do to, it is also the most trivial to bypass. It's quite easy to inject the "Referer" header into your request to bypass any protection mechanisms we have in place.

Pros

  • Simple to implement.

Cons

  • You need to be careful on how you implement the referrer check. Make sure you take into account every place that the user might click on the link to the protected page.
  • Some virus protection systems and firewalls can strip out the referrer header, which breaks the functionality here.
  • The referrer header can be easily spoofed.

It's best to not rely on the referrer header at all since it is so easy to bypass and can often be missing.

Cookie

By setting a cookie on the first page we can protect the second page by making sure the cookie is present, if it isn't then we can issue an access denied message.

To get this working we set a cookie using the PHP function setcookie() on the first page. In the example below we are setting the word "allow" in the cookie contents, which we will look for later. We also set the cookie to expire in 1 hour so that we don't allow the user blanket access to the page for all time.

// Set a 1 hour cookie for this section of the site.
setcookie("cookie_protected", 'allow',  time()+3600, '/cookie/');

On the page we want to protect we just need to detect for the presence of the cookie.

If the cookie is there then we allow access to the page by making sure it's contents match what we expect. Once we have granted access we delete the cookie by setting it's expiry time to the past.

if (!isset($_COOKIE['cookie_protected']) || $_COOKIE['cookie_protected'] !== 'allow') {
  // Cookie isn't set or doesn't have our value in it.
  header('HTTP/1.0 401 Unauthorized');
  die('Invalid cookie.');
}

// Delete the cookie.
setcookie("cookie_protected", '',  time()-3600, '/cookie/');

Deleting the cookie ensures that the user can only access the page once.

We can make this mechanism slightly more secure by setting a more secure value in the cookie. There are some examples of how we can set secure values later in this article (see CSRF or JWT).

Pros

  • Simple to implement.
  • Can allow a single view of a page by removing the access key (i.e. the cookie) once it is used.

Cons

  • Cookies can be blocked in a number of different ways, which prevents this system from working.
  • You shouldn't really be issuing cookies to your users without their consent, so before using this system you need to have a cookie consent form in place.

Dynamic Link

Another way of protecting a page is to create a dynamic link that changes based on properties that the user brings to the page.

For example, we could take the user's IP address and the time of day and encode them into a link variable.

$link = base64_encode($_SERVER['REMOTE_ADDR'] . '/' . time());

Then, we just append that link variable as a property on the page we want to protect.

<a href="/dynamic-link/protected.php?link=<?php echo $link ?>">Dynamic Link Protected</a>

The generated link looks a bit like this:

/dynamic-link/protected.php?link=MTcyLjE4LjAuNS8xNzQ1NjYxNjE4

The protected page then needs to decode the information we passed in the link and verify this against the IP address of the user and the current time. If the current user fails to match the given criteria then we deny access to the page.

// Assume that the user is not authorised.
$authorised = false;

if (isset($_GET['link'])) {
  // We found the link parameter, extract the data and verify it.
  $link = base64_decode($_GET['link']);
  list($ip, $time) = explode('/', $link);
  if ($ip === $_SERVER['REMOTE_ADDR'] || $time >= (time() - 60)) {
      // The verification checks out, allow access.
      $authorised = true;
  }
}

if ($authorised === false) {
  // Authorisation was not granded, so issue an access denied.
  header('HTTP/1.0 401 Unauthorized');
  die('Invalid link.');
}

Using the IP address here means we don't need to store any cookies or other information about the user before hand, they are stored in the link parameter. Including the time means not only will the base64 encoded string be unique for every visit, but that the link also has a built in time limit that we can work into the protection.

Pros

  • The link we generate here has a life time without us having to implement any session management systems.
  • This method is pretty robust since it doesn't rely on any sessions or cookies to be set.

Cons

  • This mechanism uses a simple base64 encode/decode method, which would be trivial to bypass if an attacker were to spend time investigating it. A more secure encryption or token encryption or validation system should be used.

Form

Users can click on links to get from page page to another, but by submitting a form we can do the same action and pass more of a payload from one page to the other.

Take this simple HTML form, which contains a hidden field that will form our protection payload. A post request will be made to the protected page when the user submits it.

<form method="post" action="protected.php">
  <input type="hidden" name="protected_form" value="true" />
  <input type="submit" value="View Protected Page" />
</form>

We need to use a "post" method here since using a "get" method will just append the property to the URL, which the user can then share with anyone.

On the other side of the submission we just need to look for the form being submitted, and that the value of our hidden field matches our expected value.

if (!isset($_POST['protected_form']) || $_POST['protected_form'] === true) {
  // The user hasn't submitted the form, deny access.
  header('HTTP/1.0 401 Unauthorized');
  die('Invalid form submission.');
}

For extra protection we can also make the protected form value be more dynamic. In it's current state our form is pretty trivial to bypass as we just need to inject the post request with non-hashed or encrypted values into our request.

See the section on CSRF later in this article for more detail on how to create a secure form token.

Pros

  • Simple to implement. Just needs a form and a submission handler.
  • Once the user submits the form and enters the protected page they would need to resubmit the form again to see the page again.

Cons

  • If the user refreshes the protected page they will see a message from the browser asking if it is ok to resubmit the previous values. This isn't a brilliant user experience but it does add a level of protection.

IP

This might seem like a silly example, but IP address restriction is fairly common so I thought I would include it here as an extra example.

For this protection mechanism to work we just need to get the users IP address and then check that the IP address is what we expect it to be. If it isn't then we just deny access to the page.

// Grab user IP address.
$ip = $_SERVER['REMOTE_ADDR'];

if ($ip !== '172.18.0.5') {
  // The IP address is not correct, issue an access denied.
  header('HTTP/1.0 401 Unauthorized');
  die('Invalid IP address');
}

Of course, for this to work you need to know the users IP address before hand. Some collecting of IP addresses and configuring of the protection page is needed before hand.

Pros

  • Simple to implement, you just need to know the IP address of the user.

Cons

  • You need to know the correct IP address (or range) to restrict before hand meaning that this isn't useful for public websites.
  • IP address spoofing is not trivial to do, but is possible.
  • IP addresses are often shared between lots of people or can change from request to request.
  • In the world of proxy servers, load balancers, and VPNs, actually finding out the IP address of a user is not a trivial matter. You need to ensure that every level of access to your application forwards the client IP address to your website.

CSRF Token

CSRF stands for "Cross-Site Request Forgery" and is a type of vulnerability caused when a page doesn't properly check that the user came from the correct place. This might seem like a trivial issue, but it can cause big problems, especially when the destination page is expecting a form submission.

For example, let's say you visit a malicious site that silently submits a post request in the background that goes to one of your social media sites. As you are logged into the social media site the post request silently makes posts on your behalf, or worse, changes your password and transfers ownership of the account to the attacker. If the social media site doesn't check where the request came from then it will accept it without query and allow the attack to take place.

It doesn't have to be a social media site, it could be a bank or credit card that is being attacked and if that site isn't preventing CSRF problems then the request will be accepted and you might end up losing money.

A CSRF token is a mechanism where we can protect against this sort of attack. We register a token and then check to see if the incoming request (however it was generated) has the same token in the page request. If is doesn't then we can assume that the user didn't come from the correct place and block it.

For the purposes of protecting a page, a CSRF token is perfect since it is designed around ensuring that the user came from the page that the CSRF token was generated. All we need to do is generate a random token, using a number of random characters, hashed using a secure hashing value. We then store that token in our session for later checking.

// Start the session so we can store the CSRF token there.
session_start();

// Generate the CSRF token.
$csrf = hash('sha256', bin2hex(random_bytes(15)));

// Store the CSRF token in the session.
$_SESSION['csrf'] = $csrf;

CSRF tokens can be embedded into hidden fields in forms, but for this purpose we just create a URL parameter that gets embedded in the link to the protected page.

<a href="/csrf/protected.php?csrf=<?php echo $_SESSION['csrf']; ?>">CSRF Protected</a>

When we load the protected page we start the session to grab our generated CSRF token from the session data. We then check this value with the CSRF token passed to the page via the parameter. If they don't match then we deny access.

// Start the session so that we can grab the CSRF token from it.
session_start();

// Get the passed CSRF token and the one stored in the session.
$passedCsrf = $_GET['csrf'] ?? FALSE;
$csrf = $_SESSION['csrf'] ?? FALSE;

if ($passedCsrf === FALSE || $csrf === FALSE || $passedCsrf !== $csrf) {
  // The tokens do not match, deny the user access.
  header('HTTP/1.0 401 Unauthorized');
  die('Invalid CSRF');
}

As the CSRF token is randomly generated on every page request it is not possible for a user to guess the token, and so the page remains protected.

Pros

  • This is quite a secure mechanism, even with an insecure hashing algorithm like MD5 you still need the token and the session to be in place for this to work.
  • The generated token can't be passed onto other users, but the page can be loaded more than once if the user desires.

Cons

  • A session is required for this mechanism to work correctly. If the user is blocking cookies then this won't work correctly.

JWT

JSON Web Tokens, or JWT, are an open standard (see RFC 7519) that allows two parties to verify messages between each other. It is commonly used to verify user identities between two different systems, and even authenticate them, and are commonly used in APIs and mobile apps.

I could write a (quite lengthy) article about how JWT works and what hashing mechanisms are available to be used when generating the JWT. To put things in their simplest terms, we generate a key that we will use to create a hash value of a payload, which we can then use in the request. We can then deconstruct this payload and verify it's contents using the original key. This ensures that the payload of the JWT we receive exactly matches the payload the the JWT that was generated. 

JWT isn't cryptographically secure, but it does ensure that any data passed between two parties can be validated as being correct.

To get started with JWT we need to define a key to use, which we set as a PHP constant.

const JWT_KEY = '9r87wde09r80w9ercqw98rcu436o5j4klhkrehjtrethutq2qurwiru';

Note: Make sure this key remains secure. Don't ever commit this key to your repository or leave it in an insecure location or you will not be able to verify that the JWT came from your services. 

This is then used to hash a payload of the request, which is essentially an array that contains some information about the user.

Rather than post the code for the encoding and decoding of JWT tokens here, I'll link to the example GitHub repo for this article that contains the code you need to encrypt and decrypt JWT data in PHP. From this example code we now have a function called jwt_encode() that encodes our payload into the JWT, and a function called jwt_decode() that decodes the JWT into the payload and verifies that the signatures of the payload match correctly.

On the first page, we create a payload that contains the user's IP address and the current timestamp, which we then encode into a JWT.

// Generate our payload.
$payload = [
  "ip" => $_SERVER["REMOTE_ADDR"],
  "time" => time(),
];

// Encode our payload.
$jwt = jwt_encode($payload, JWT_KEY);

Since the payload is just a string we can then add it into the protected link.

<a href="/jwt/protected.php?jwt=<?php echo $jwt; ?>">JWT Protected</a>

On the protected page we just need to get our token, decode it, and then also verify that the decoded values match against the user information we currently have. The decoding of the JWT may fail if the signature of the token isn't correct, in which case we can't validate the authenticity of the token and deny the user.

// Get the token from the request.
$jwt = $_GET["jwt"] ?? '';

// Attempt to decrypt
try {
  $jwt = jwt_decode($jwt, JWT_KEY);
} catch (Exception $e) {
  header('HTTP/1.0 401 Unauthorized');
  die('Invalid JWT');
}

if (!isset($jwt['ip']) || !isset($jwt['time']) && ($jwt['ip'] !== $_SERVER["REMOTE_ADDR"] && $jwt['time'] <= (time() - 60))) {
  header('HTTP/1.0 401 Unauthorized');
  die('Invalid JWT');
}

In the above example we are using the user's IP address and the current time. It is a good idea to use time based JWT as this means that they can't be used for very long so if your key does get compromised then any currently existing JWT tokens will quickly expire and be useless.

Pros

  • Although the payload isn't secure here, we can ensure that the JWT we receive was created using the same key and contains verified information.

Cons

  • This isn't encryption, so it's not possible to handle any secure data. A dedicated attacker can decrypt this payload and inspect its contents, but they can't change it as it would invalidate the JWT signature.
  • This is somewhat complex to implement. There's a fair amount of complex code needed to get this all working and debugging JWT issues can be somewhat complex.

Basic Authentication

I realise this is technically authentication, but I decided to include this as it's very simple to implement. It is also simple to inject the credentials into the URL when linking to the page.

All we need to do on this page is setup the protected page to pull the basic authentication details from the request and make sure they match. PHP gives these fields to us for free by wrapping them in the $_SERVER superglobal.

// Grab user credentials.
$user = $_SERVER['PHP_AUTH_USER'] ?? '';
$pass = $_SERVER['PHP_AUTH_PW'] ?? '';

// Validate the credentials.
$validated = ($user === 'user') && ($pass === 'pass');

if (!$validated) {
    // Issue authentication header.
    header('WWW-Authenticate: Basic realm="Protected Page"');
    header('HTTP/1.0 401 Unauthorized');

    die('Unauthorised');
}

To make this invisible for the user you just need to inject the authentication details into the link.

<a href="//user:[email protected]/basicauth/protected.php">Basic Auth Protected</a>

When the user clicks on this link it will transmit the credentials to the page protected by basic authentication and log them in.

Pros:

  • Just needs a single header and validation check.

Cons

  • The user credentials for the request will need to be fully visible to the user, in which case it is a trivial matter of rebuilding them and creating their own links.
  • You can't log the user out. Once the are in, there's not much chance of preventing their access.

Conclusion

Outside of requiring the user to authenticate there are a number of different mechanisms available to protect a page. Generally, the most secure mechanisms are ones in which a token is created that can be validated when the user hits the protected page.

Using the IP address of the user is often not enough to protect the page as this can be easily spoofed, but if you are able to create a session variable for the user then you can use this to ensure the user matches. The stateless nature of PHP requests means that you need to verify that the user is who they say they are after the jump between the unprotected page and the protected page.

If you are looking to protect a page for your own projects I would look into CSRF tokens first, probably followed by JWT if you are unable to use sessions. As these are both proven technologies there is plenty of documentation available for each. I would also encourage you to embed CSRF tokens into a form, rather than as a URL parameter as that better protects the protected page against repeated visits.

Although this is a long list of examples I am wondering if I have missed any examples out. If you can think of any page protection mechanisms that do not rely on user authentication then please let me know in the comments.

I should also point out that although I have made every effort to make these examples as comprehensive as possible they should not be used in production environments without thoroughly testing the outcomes. Also, if you are sending user information over the internet then should should always use HTTPS to ensure the security of the request.

All of the code here can be seen in the example repository that accompanies this article, which also contains instructions on how to get up and running with the project.

Add new comment

The content of this field is kept private and will not be shown publicly.