Protect Forms with CSRF Token in PHP

Just making an eye catching website is not enough. Keeping a website secure is one of biggest challenges for web developers. In this post we will understand what CSRF is, how can it harm a website and how to make your website CSRF protected.

Protect Forms with CSRF Token in PHP

What is CSRF?

CSRF (Cross-Site Request Forgery) also known as one-click attack or session riding, is a forged request approach an attacker uses to attack its victim without victim even noticing it. The victim is tricked to perform an action that he did not intend to. For a quick example lets assume a user can transfer payments to other users within the same system and the form is using POST method to transfer payment. The request looks like this:

<body onload="document.forms[0].submit()">
    <form action="http://paymentsystem.com/funds-trasfer" method="POST">
        <input type="hidden" name="account_number" value="attackers_account"/>
        <input type="hidden" name="amount" value="1000"/>
        <input type="submit" value="How to earn $100 a day"/>
    </form>
</body>

Now knowing pattern of request an attacker can very well prepare a page where he has set up this form for victim, All attacker has to do is send the link to this page to a victim who is already logged in the system. There are many ways to protect website forms against such forged requests. Most commonly used is generate a random unique token to be sent along with request to verify.I am going to show you how can you implement CSRF protection on forms.

config.php

Configurations for csrf like enabling csrf, token name, and expiry goes here.
<?php
global $config;

$config['csrf_protection'] = true;
$config['csrf_token_name'] = 'csrf_token';
$config['csrf_token_expire'] = 300;

/**
* Loads an error page for failed verification of CSRF token
*/
if(!function_exists('show_error')){
function show_error(string $heading, string $message){
include 'errors/general.php';
die(0);
}
}

csrf.php

CSRF class which will be performing actions such as generating unique token, verify token sent in request, showing input field for form.

<?php
class csrf{

private static string $csrf_token_name;
private static string $csrf_token_expire;

public static function construct()
{
global $config;

self::$csrf_token_name = $config['csrf_token_name'];
self::$csrf_token_expire = $config['csrf_token_expire'];

self::generate_token();
}

/**
* Generates a new token and sets token along with expire time in session
*/
public static function generate_token()
{
if(empty(filter_input_array(INPUT_POST)) && empty(filter_input_array(INPUT_GET))){

$csrf_token = bin2hex(random_bytes(16));

$_SESSION[self::$csrf_token_name] = $csrf_token;
$_SESSION['csrf_token_expire'] = time() + intval(self::$csrf_token_expire);
}
}

/**
* Returns the generated token or creates a new token and returns
* @return string
*/
public static function get_token(): string
{

if(isset($_SESSION[self::$csrf_token_name])){
$csrf_token = $_SESSION[self::$csrf_token_name];
}else{
$csrf_token = self::generate_token();
}

return $csrf_token;
}

/**
* Checks if token exists in request and it matches the generated token in session
* @return void
*/
public static function verify_token(): void
{

$request = filter_input(INPUT_SERVER, 'REQUEST_METHOD', FILTER_SANITIZE_FULL_SPECIAL_CHARS);

$methods = ['GET' => filter_input_array(INPUT_GET), 'POST' => filter_input_array(INPUT_POST)];

$token_name = self::$csrf_token_name;

if(!empty($methods[$request])){
if(!isset($methods[$request][$token_name])){
show_error('CSRF ERROR', 'No CSRF token was sent');
}

if($_SESSION['csrf_token_expire'] < time()){
show_error('CSRF ERROR', 'CSRF token has expired');

}

if($methods[$request][$token_name] !== $_SESSION[$token_name]){
show_error('CSRF ERROR', 'CSRF token did not match. Please reload page');
}
}
}

/**
* Returns token field name
* @return string
*/
public static function get_token_name(): string
{
return self::$csrf_token_name;
}

/**
* Returns CSRF token input field
* @return string
*/
public static function get_token_field(){
return '<input type="hidden" name="csrf_token" value="'.self::get_token().'" />';
}
}

// Generate the token on first load
csrf::construct();

index.php

Contains example form with csrf token included.
<?php
session_start();

include 'config.php';

// Implement CSRF protection if enabled in config
if($config['csrf_protection']){
include 'csrf.php';
csrf::verify_token();
}
if(!empty(filter_input_array(INPUT_POST))){
// Process form data here
}
?>
<!DOCTYPE html>
<html>
<head>
<title>Protect Forms with CSRF Token in PHP - Demo</title>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/>
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<div class="container">
<div class="my-4">
<p>Open this same page in new tab and click submit on this page to test csrf.</p>
<form name="csrf_form" method="POST">
<?=csrf::get_token_field();?>
<button type="submit" class="btn btn-green">Submit</button>
</form>
</div>
</div>
</body>
</html>

errors/general.php

An error to show an error when token verification is failed.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>CSRF Error</title>
<style type="text/css">
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
}
body {
background-color: #f6f6f6;
font-family: "Segoe UI", "Roboto", "Helvetica", sans-serif;
font-size: 15px;
font-weight: normal;
font-style: normal;
line-height: 1.5;
}
.container {
width: 100%;
max-width: 1140px;
margin-right: auto;
margin-left: auto;
padding-right: 15px;
padding-left: 15px;
}
.error-wrapper {
border: 1px solid #d0d0d0;
margin-top: 1rem;
margin-bottom: 1rem;
}
.error-heading {
color: #e42c2c;
background: #d0d0d0;
border-bottom: 1px solid #d0d0d0;
padding: 0.5rem 1rem;
}
.error-title {
margin: 0;
font-weight: 600;
}
.error-body {
padding: 1rem;
}
</style>
</head>
<body>
<div class="container">
<div class="error-wrapper">
<div class="error-heading">
<h1 class="error-title"><?= $heading; ?></h1>
</div>
<div class="error-body"><?= $message; ?></div>
</div>
</div>
</body>
</html>

style.css

All the style for index.php are wrapped in this stylesheet.
*{
box-sizing: border-box;
}
html,body{
margin: 0;
padding: 0;
}
body{
background-color: #f6f6f6;
font-family: "Segoe UI", "Roboto", "Helvetica", sans-serif;
font-size: 15px;
font-weight: normal;
font-style: normal;
line-height: 1.5;
}
.container{
width: 100%;
max-width: 1140px;
margin-right: auto;
margin-left: auto;
padding-right: 15px;
padding-left: 15px;
}
.my-4{
margin-top: 1rem;
margin-bottom: 1rem;
}
.btn {
display: inline-block;
padding: 5px 10px;
cursor: pointer;
font: inherit;
}
.btn-green {
background-color: #00a65a;
border: 1px solid #009549;
color: #ffffff;
}