Homemade API Tokens Using PHP's Hash Functions
9 minutesⓘ Updated October 2026: the original version of this post passed the arguments to hash_hmac() in the wrong order, called the key "single-use" when it wasn't, and oversold how much protection it gives. The code and the demos below are fixed, and there's a new section at the end on what this does and doesn't protect against.
When I built this blog from scratch, I wanted the comments to lazy load once you scrolled down to them, similar to how YouTube does it. Some JavaScript on the page would make a request back to a PHP script on my server, which would send the comments back as JSON.
Even though my site is small, I wanted to practice a little security. My goal was for that endpoint to only answer requests that came from one of my pages in the last few minutes, so an old link to it would stop working. (Not that there's any real value in this data.)
PHP already provides almost everything we need for this. Today we'll go over the PHP hash functions, then use them to build a small JSON endpoint that only accepts short-lived signed tokens.
So, what is a hash?
A hash function takes any amount of input data and turns it into a fixed-length string, called a hash (or digest). A good hash function has a few useful properties:
- It's deterministic. The same input always produces the same hash.
- It's one-way. Given a hash, it's practically impossible to work out the input that produced it.
- Small changes are obvious. Change a single character of the input and you get a completely different hash, so you can't tamper with data without the hash changing.
This is how websites avoid storing passwords as plain text. When you sign up, the site saves a hash of your password. When you log in, it hashes what you typed and checks it against the saved hash. Tom Scott has an awesome video explaining this process:
ⓘ For real passwords, use PHP's password_hash() and password_verify(), not a fast hash like SHA-256. They add a random salt and are deliberately slow, which makes guessing passwords much harder.
Adding a secret: HMAC
A plain hash isn't enough for our endpoint. If the token were just hash('sha256', $timestamp), anyone could compute a valid token for any time they liked, because SHA-256 is public.
An HMAC (hash-based message authentication code) mixes a secret key into the hash. Anyone can see the data and the resulting HMAC, but only someone who knows the key can produce a matching HMAC for new data. If the data is changed, the HMAC no longer matches. PHP gives us this with hash_hmac():
hash_hmac(string $algo, string $data, string $key): string
Note the order: the data comes before the key. (The first version of this post had them backwards.)
The plan
- When the server builds a page, it takes the current timestamp and signs it with
hash_hmac()using a secret key that only the server knows. - The page sends the timestamp and the signature along with its request to the API.
- The API signs the timestamp again with the same secret. If the result matches the signature that was sent, the server knows it issued that timestamp. If the timestamp is more than 5 minutes old, the request is rejected anyway.
The timestamp isn't secret, and doesn't need to be. The signature is what proves it came from us.
The code
Both the page and the API need the secret and the signing code, so they live in one shared file:
hash-auth.php:
<?php
// Shared by the page that hands out tokens and the API that checks them.
// In a real project, keep the secret out of your code and out of your web root
// (a config file one folder up, or an environment variable).
const API_SECRET = 'replace-this-with-your-own-long-random-secret'; // bin2hex(random_bytes(32))
const TOKEN_LIFETIME = 300; // seconds a token stays valid
function signTimestamp(string $issuedAt): string {
// hash_hmac($algorithm, $data, $key): the timestamp is the data, the secret is the key
return hash_hmac('sha256', $issuedAt, API_SECRET);
}
function issueToken(): array {
$issuedAt = (string) time();
return ['issuedAt' => $issuedAt, 'signature' => signTimestamp($issuedAt)];
}
// Returns null when the token is good, or a reason when it isn't.
function tokenError($issuedAt, $signature): ?string {
if (!is_string($issuedAt) || !ctype_digit($issuedAt) || !is_string($signature)) {
return 'Missing or malformed token';
}
if (!hash_equals(signTimestamp($issuedAt), $signature)) {
return 'Invalid signature';
}
$age = time() - (int) $issuedAt;
if ($age < 0 || $age > TOKEN_LIFETIME) {
return 'Token expired';
}
return null;
}
A few things worth pointing out:
- The secret should be long and random. You can generate one with
php -r "echo bin2hex(random_bytes(32));". Don't reuse the one from a tutorial, and don't commit it to a public repo. hash_equals()compares the two strings in constant time. A normal==comparison can stop at the first character that differs, and in theory that timing difference lets an attacker guess a signature one character at a time.- The signature is checked before the timestamp is trusted. Until the signature matches, the timestamp is just text a stranger typed into a URL.
- The input is validated first.
$_GETvalues can be missing, or even arrays (?issuedAt[]=1), and passing those tohash_hmac()would throw an error.
ⓘ SHA-256 is our hashing algorithm here. There are many options, but SHA-256 is a good default.
Now the API itself. Along with the security side, it has to send the data back in the right format. We tell the browser what format we're using with a MIME type header (the MIME type for JSON is application/json), and we encode the data with PHP's json_encode(). Errors are sent as JSON too, with a 401 Unauthorized status code, so the JavaScript on the other end can always parse the response.
hash-api.php:
<?php
require __DIR__ . '/hash-auth.php';
header('Content-Type: application/json');
header('Cache-Control: no-store');
$error = tokenError($_GET['issuedAt'] ?? null, $_GET['signature'] ?? null);
if ($error !== null) {
http_response_code(401); // Unauthorized
echo json_encode(['error' => $error]);
exit;
}
$dataToReturn = [
'title' => 'It worked!',
'properties' => [
'Color' => 'blue',
'State' => 'UT',
'Brand' => 'Toyota',
],
];
echo json_encode($dataToReturn, JSON_PRETTY_PRINT);
If you open the API directly, without a token, it turns you away:
Finally, the page that gets a token from the server and makes the request. To show the API working, the demo sends three requests: one with a valid token, one where the signature has been tampered with, and one with a correctly signed token that's 10 minutes old. In a real page you'd only send the first one.
hash-generateAndRequest.php:
<?php
require __DIR__ . '/hash-auth.php';
$token = issueToken();
// Two bad tokens, so you can see the API turn them away
$tenMinutesAgo = (string) (time() - 600);
$examples = [
'Valid token' => $token,
'Tampered signature' => ['issuedAt' => $token['issuedAt'], 'signature' => str_repeat('0', 64)],
'Expired token' => ['issuedAt' => $tenMinutesAgo, 'signature' => signTimestamp($tenMinutesAgo)],
];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hash API demo</title>
<style>
body { font: 13px/1.4 monospace; margin: 12px; overflow-wrap: anywhere; }
pre { white-space: pre-wrap; margin: 4px 0 14px; }
</style>
</head>
<body>
<div id="results"></div>
<script>
const examples = <?= json_encode($examples, JSON_HEX_TAG) ?>;
const results = document.getElementById('results');
async function request(label, token) {
const url = 'hash-api.php?' + new URLSearchParams(token);
let output;
try {
const response = await fetch(url, {cache: 'no-store'});
const data = await response.json(); // errors come back as JSON too
output = response.status + ' ' + JSON.stringify(data);
} catch (error) {
output = 'Request failed: ' + error;
}
const heading = document.createElement('strong');
heading.textContent = label + ': GET ' + url;
const pre = document.createElement('pre');
pre.textContent = output;
results.append(heading, pre);
}
(async () => {
for (const [label, token] of Object.entries(examples)) {
await request(label, token);
}
})();
</script>
</body>
</html>
json_encode() hands the tokens to JavaScript safely, and URLSearchParams builds the query string, so nothing has to be pasted into a URL by hand.
What this does and doesn't protect against
This is the part I got wrong the first time, so it's worth being clear about.
What it does: the API only answers requests carrying a timestamp that my server signed in the last 5 minutes. Old or copied links stop working, and nobody can make up their own token without the secret key. This is the same idea behind signed, expiring links like a temporary download URL.
What it doesn't do:
- It isn't authentication. The token is printed into a public web page, so anyone who loads the page (or a script that downloads it) can copy a fresh token and call the API with it. It proves "this request came from someone who loaded my page recently", not "this request came from a particular person".
- It isn't single-use. A token can be used any number of times until it expires. To make tokens single-use, the server has to remember which ones it has already accepted (in a database table or a cache) and reject repeats until they expire.
- It doesn't limit how often someone calls the API. If you're worried about abuse, rate limit requests per IP address.
If you need to know who is making a request, use a real login with sessions, or an API key that's issued to each user and kept private. One easy improvement to this scheme is to sign what the token is for along with the time, like hash_hmac('sha256', "comments|$postId|$issuedAt", API_SECRET), so a token issued for one post can't be used to fetch another.
Hopefully you've learned something!
Further reading: