Drop session_start() from Your Paywall: HMAC-Signed Cookies and Single-Use Recovery Links
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-store and 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 $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 trailingexpires witharray_pop and 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 expires attribute 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 ) $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)
Comments
No comments yet. Start the discussion.