session_start() does not just lose a paywall purchaser when the browser closes — it kills page caching for every visitor. Here is the replacement: an HMAC-signed cookie plus a single-use recovery link, and the design decisions behind both.
Premise: sessions cannot coexist with page caching
When you build paid articles (a paywall) that can be bought without registering an account, the first thing you decide is: "how do I remember, on the next request, that this reader is a purchaser?"
The old implementation I maintained did this with session_start() and $_SESSION. It works, but it has two problems.
- It disappears when the browser closes. A purchaser returning the next day looks like they have to buy again
-
It collides with page caching.
session_start()emits headers equivalent toCache-Control: no-storeand handsSet-Cookie: PHPSESSID=...to every visitor. Most CDNs and page caches will not cache a response carrying Set-Cookie, so caching is effectively disabled for everyone, purchasers and non-purchasers alike
The second one was fatal. A paywall is normally shaped as "most of the article is free, only the continuation is paid", so the free portion is exactly where caching should be working.
This article records the design that replaced it, in two parts, and the judgment calls I made while implementing it.
- An HMAC-signed cookie (90 days by default) identifies the reader without server-side state
- A single-use link (24 hours by default, one use) restores access from another device
The code is from a WordPress plugin, but the design of signed cookies and single-use links is framework-independent.
1. A signed cookie with no server-side state
The cookie contents are two parts: base64url(email|expires) . '.' . hmac.
public function build_cookie_value( $email, $expires ) {
$email = ACSCW_Access::normalize_email( $email );
if ( '' === $email ) {
return '';
}
$payload = $email . '|' . (int) $expires;
$encoded = self::base64url_encode( $payload );
$signature = hash_hmac( self::ALGO, $encoded, $this->get_signing_key() );
return $encoded . '.' . $signature;
}
There are three things to be careful about on the verification side.
public function verify_cookie_value( $raw ) {
$raw = (string) $raw;
if ( '' === $raw || substr_count( $raw, '.' ) !== 1 ) {
return false;
}
list( $encoded, $signature ) = explode( '.', $raw, 2 );
$expected = hash_hmac( self::ALGO, $encoded, $this->get_signing_key() );
// Compare with hash_equals to avoid timing attacks.
if ( ! hash_equals( $expected, $signature ) ) {
return false;
}
$payload = self::base64url_decode( $encoded );
if ( false === $payload || false === strpos( $payload, '|' ) ) {
return false;
}
// Take expires from the end, so an email containing '|' is still fine.
$parts = explode( '|', $payload );
$expires = (int) array_pop( $parts );
$email = ACSCW_Access::normalize_email( implode( '|', $parts ) );
if ( '' === $email || $expires <= time() ) {
return false;
}
return array( 'email' => $email, 'expires' => $expires );
}
-
Compare signatures with
hash_equals.===varies in execution time by how many leading characters match, so do not use it -
Do not fix the split count of
explode. Take only the trailingexpireswitharray_popand rejoin the rest. That structurally prevents the "delimiter appears inside the value" class of mistake -
Put the expiry inside the payload so it is covered by the signature. The cookie's own
expiresattribute can be freely ignored by the client, so do not rely on it alone
Keep the signing key separate from the framework's salt
WordPress has an existing key, wp_salt('auth'), but rather than using it directly I mix in a plugin-specific random key.
private function get_signing_key() {
$secret = get_option( self::KEY_OPTION );
if ( ! is_string( $secret ) || strlen( $secret ) < 32 ) {
$secret = wp_generate_password( 64, false, false );
update_option( self::KEY_OPTION, $secret, false );
}
return hash_hmac( self::ALGO, $secret, wp_salt( 'auth' ) );
}
The purpose is one operational capability: being able to invalidate every access cookie you have issued, without taking login sessions down with it. Throwing away one dedicated option expires all issued access cookies at once. If you used the shared salt directly, that operation would force-log-out every user.
Cookie attributes on emission: httponly, samesite=Lax, secure depending on is_ssl(), and the name carries COOKIEHASH (to avoid collisions when several sites share a domain).
2. You cannot put a nonce in a single-use link
Recovery on another device goes out by email. The first thing that tripped me up here was handling the CSRF token (a nonce, in WordPress terms).
You cannot put a nonce in an emailed link. A nonce is bound to "the person who has this session right now", and at the moment you send the email you do not know which browser or session the recipient will open it in. You cannot create a nonce that will be valid at send time.
Instead, treat the 48-character single-use token itself as the credential.
public function create_one_time_token( $email, $post_id = 0 ) {
$token = wp_generate_password( 48, false, false );
$stored = set_transient(
self::TOKEN_TRANSIENT_PREFIX . self::hash_token( $token ),
array( 'email' => $email, 'post_id' => absint( $post_id ) ),
$this->get_token_lifetime()
);
// ...
return $token;
}
What is stored is only the hash of the token. The raw value exists nowhere except the body of the email. Even if storage leaks, no valid link can be assembled from it. It is the same reasoning as not storing passwords in plain text, applied to a temporary token with a 24-hour lifetime.
On the consuming side, delete it the moment it is read.
public function consume_one_time_token( $token ) {
$token = preg_replace( '/[^A-Za-z0-9]/', '', (string) $token );
if ( '' === $token ) {
return false;
}
$key = self::TOKEN_TRANSIENT_PREFIX . self::hash_token( $token );
$value = get_transient( $key );
// Single use. Delete it as soon as it is readable.
delete_transient( $key );
if ( ! is_array( $value ) || empty( $value['email'] ) ) {
return false;
}
// ...
}
The point is that delete_transient() sits before validation. Put it after, and as soon as you add another early-return path in validation, you create a path that skips the deletion.
Do not leave the token in the URL
The handler for an opened link issues the cookie, then drops the query variable and redirects to the same URL.
public function maybe_consume_access_link() {
if ( empty( $_GET[ self::QUERY_VAR ] ) ) {
return;
}
$token = sanitize_text_field( wp_unslash( $_GET[ self::QUERY_VAR ] ) );
$result = $this->consume_one_time_token( $token );
if ( $result ) {
$this->issue_cookie( $result['email'] );
}
$redirect = remove_query_arg( self::QUERY_VAR );
if ( ! $result ) {
// Put the failure reason in the URL (never the email address).
$redirect = add_query_arg( 'acscw_notice', 'link_expired', $redirect );
}
wp_safe_redirect( $redirect );
exit;
}
A URL with the token still in it leaks through browser history, the Referer header, and pasting into social media. The actual harm is small since it is already used, but having it gone from the address bar saves the reader from wondering "is this URL safe to share?".
Also, the entry point is template_redirect at priority 1, not REST or AJAX. Email links are opened as ordinary GET requests, so handling them at the very front of a normal front-end request and redirecting was the natural fit.
3. Do not turn the recovery form into an email existence oracle
This was the easiest place in the design to get wrong. A form that says "send an access link to this email address" becomes a purchaser-lookup API if you implement it naively.
public function request_access_link( $email, $post_id = 0 ) {
// ... format check, rate limiting ...
// Return true even when there is no purchase. Miss this branch and it becomes an existence oracle.
if ( ! $access->has_any_access( $email ) ) {
return true;
}
$token = $this->create_one_time_token( $email, $post_id );
// ... send mail ...
return true;
}
An address with no purchase gets exactly the same result (true) as a purchaser. Mail is actually sent only when there is a purchase. The screen always says "We have sent an access link. If it does not arrive, please check your spam folder."
The only cases where returning WP_Error is acceptable are malformed input and rate-limit exceeded. Those two are unrelated to whether the address is registered, so returning them leaks nothing.
Rate limiting on two axes, with no raw values in the key
The submit form can be hit infinitely, so it is throttled on two axes: per email address (180 seconds between requests by default) and per IP (10 per hour by default).
private function get_rate_key( $scope, $value ) {
return self::RATE_TRANSIENT_PREFIX . $scope . '_'
. substr( hash_hmac( self::ALGO, (string) $value, $this->get_signing_key() ), 0, 32 );
}
Do not put a raw email address or IP into a transient key. Key names can show up verbatim in cache admin screens and debug output, and you do not want that to become a store of personal data. Flattened through an HMAC, a listing of the keys tells you nothing about whose they are.
IP retrieval prefers WC_Geolocation::get_ip_address() since WooCommerce is assumed present. Aligning with the framework you already live alongside causes fewer accidents than deciding your own proxy-handling policy.
4. Verification results
The design is regression tested on a shim harness that runs under plain PHP. Re-running it at the time of writing gave the following (PHP 8.5.7 / CLI):
== 5. Signed cookies (ACSCW_Access_Token)
PASS cookie value contains no raw email address
PASS changing one character of the signature fails
PASS an expired cookie does not verify
PASS changing the signing key invalidates existing cookies
== 6. Single-use links
PASS the raw token is not stored (hash only)
PASS the same token cannot be used twice (single use)
PASS a token with symbols mixed in does not raise
PASS the token is removed from the redirect target
== 7. Access recovery requests, rate limiting, mail
PASS the same result is returned even without a purchase (prevents existence probing)
PASS no mail is sent to a non-purchaser
PASS repeated requests from one IP stop at the limit
----------------------------------------
PASS 168 / FAIL 0
What works well here is encoding the design decisions themselves as tests. "Changing the signing key invalidates existing cookies" and "the same result is returned even without a purchase" are exactly the properties a refactor breaks if you do not know the spec — and when they break, the screen still looks fine. The test names double as the specification.
Summary
- The motivation for dropping sessions is not only "it disappears" but, more importantly, "it kills caching for every visitor"
- Build signed cookies on three things:
hash_equals, an expiry inside the payload, and a dedicated signing key - You cannot put a nonce in an emailed link. Make the single-use token itself the credential, and store only the hash, delete before validating
- The recovery form returns the same response for unregistered addresses. Only malformed input and rate-limit exceeded may be errors
- Never put a raw email address or IP into a rate-limit key
I publish further verification records and related tools on ACS Developer.
Originally published in Japanese on Zenn: https://zenn.dev/acs_developer/articles/signed-cookie-one-time-link-paywall
Top comments (0)