An alternative simple solution.
Creative uses of Nonces
WordPress has internal system to generate unique hash that can be then verified: nonces. Normally nonces are used to prevent CSRF attacks, but considering that out-of-the-box a WP nonce:
- is an hash
- may be verified
- is valid only for a limited frame of time
- is coupled to a specific user
It fits quite well all your needs.
No rewrite rules
I'll be honest: I don't like WordPress rewrite API. Rules are stored in database, they need to be "flushed" before being used, and the API itself is far from ideal.
In cases like this, I would just go a bit low-level, and use the 'do_parse_request'
filter hook to check the secret url and set proper vars if needed.
The url format
For the code below I'll assume a link like:
home_url( '/s/'. $post_id . '|' . wp_create_nonce('my-cpt'.$post_id) )
This makes use of nonces so it is unique per-user and even the same user can use it for a limited amount of time, by default 1 day, but it can be changed using 'nonce_life'
filter.
It will look like this https://example.com/s/MTUy|ab5f9370de
.
Decript url and send to post
With 'do_parse_request'
filter, we can intercept the request for an url like the one above before is parsed by WordPres, and we can prevent WordPress to further process the url.
is_admin() or add_filter('do_parse_request', function($do, $wp) {
// quick way to get current url
$url = trim(esc_url_raw(add_query_arg(array())), '/');
// check if WP has some subfolder in home url
$path = trim(parse_url(home_url(), PHP_URL_PATH), '/');
$path and $path .= '/';
// this is not one of ours secret urls, just do nothing
if (strpos($url, $path.'s/') !== 0) {
return $do;
// extract post id and nonce from url
$sectretUrl = explode('|', preg_replace('~^'.$path.'s/~', '', $url), 2);
$id = (int) base64_decode(urldecode($sectretUrl[0])) ;
$nonce = empty($sectretUrl[1]) ? false : $sectretUrl[1];
// verify nonce, if not valid let WordPress continue the flow
// that very likely ends on a 404
if (!$id || !$nonce || ! wp_verify_nonce($nonce, 'my-cpt'.$id)) {
return $do;
// everything ok, let's set query var and tell WP don't parse request
$wp->query_vars = array('p' => $id);
$wp->is_secret_ok = $id;
return false;
}, PHP_INT_MAX, 2);
That's it, it works. All with a single code snippet of just 15 lines of code (excluding comments). Not even need to flush rewrite rules.
But you still have a problem.
Disabling standard access
If you look at the secret url example I wrote above, https://example.com/s/MTUy|ab5f9370de
, it's quite easy to understand that first part is something base64-encoded.
If someone tries to decode it, will find the post id. And using an url like "http://example.com?p={$decoded_id}"
that person will be able to view the post.
Moreover, I guess that the title of the post is visible. If you use the slug that WordPress autogenerates, using an url like "http://example.com?p={$guessed_slug}"
your post will be visible again.
This means you need to prevent access to standard url for the CPT post. Maybe only allow privileged users, like administrators and editors.
add_action('template_redirect', function() {
// when not our CPT or user is privileged, do nothing
if (! is_singular('my-cpt') || current_user_can('edit_others_posts')) {
return;
}
global $wp;
// if this is from "standard" url, exit with error
if (! isset($wp->is_secret_ok) || $wp->is_secret_ok !== (int) get_queried_object_id()) {
wp_die('Not allowed.');
}
// prevent canonical redirect that will ends in a 404 request
add_filter('redirect_canonical', '__return_false');
}, -1);
To prevent access to users that comes from "standard" url, I used a variable $wp->is_secret_ok
that I set in the previous code snippet, when the hashed url is verified.
Creating the secret CPT link
The url that we need is a bit complex, so we may want to create a function that builds it, taking as param the post ID.
It is also possible to use that function to filter the permalink and let WordPress automatically output the "secret" url when you just call the_permalink()
.
Something like this:
function my_cpt_secret_url($postId) {
$id = urlencode(base64_encode((string)$postId));
return home_url('/s/'.$id.'|'.wp_create_nonce('my-cpt'.$postId));
}
is_admin() or add_filter('post_type_link', function($link, $post) {
if ($post->post_type === 'my-cpt') {
return my_cpt_secret_url($post->ID)
return $link;
}, 30, 2);