An ecological consultancy hired me to digitise their reporting workflow. Field surveys, FFH pre-checks, monitoring protocols — nine different report templates, each with mandatory fields, a corporate letterhead, and case numbers that can't collide.
The interesting requirement: field staff have to fill in the reports on site, but most of them don't have WordPress accounts. Seasonal hires, freelancers, interns. Creating a user, assigning capabilities, deactivating later — too much friction.
So they need guest access. Per report. With the right scope. With a clean expiry.
Here's how I built it without a cron job.
The shape of the problem
Each report instance lives in a custom table:
CREATE TABLE fm_form_instances (
id BIGINT PRIMARY KEY,
project_id BIGINT,
template_id BIGINT,
status VARCHAR(32), -- 'entwurf' | 'abgeschlossen'
data_json LONGTEXT,
...
);
The state machine is intentionally tiny: a report is either draft (entwurf) or completed (abgeschlossen). Once completed, it can't be edited. The PDF is generated, the cloud backup runs, done.
Guest access only makes sense for drafts. Once the report is submitted, the link should die. The question is how it dies.
The naive options
Option A: cron job. Run hourly, find expired tokens, delete them. Predictable, but it requires WordPress cron to actually fire (unreliable on low-traffic sites) and adds a moving part.
Option B: TTL on the token. Store an expires_at timestamp, check it on every request. Simple, but you have to pick a duration. Too short and field staff get logged out mid-report. Too long and abandoned tokens linger.
Option C: tie the token's lifecycle to the report's lifecycle. No timer. No cron. The token dies the moment its purpose is fulfilled.
I went with C.
The pattern
class FM_Guest_Access {
public function create_for_instance($instance_id, $password, $created_by = 0) {
global $wpdb;
$instance = $wpdb->get_row($wpdb->prepare(
"SELECT id, status FROM {$this->instances_table} WHERE id = %d",
$instance_id
), ARRAY_A);
// Only drafts can receive a guest link.
if (!$instance || $instance['status'] !== 'entwurf') {
return false;
}
$token = wp_generate_password(40, false, false);
$password_hash = wp_hash_password($password);
// Exactly one active access per instance.
$wpdb->delete($this->table, ['instance_id' => $instance_id], ['%d']);
$wpdb->insert($this->table, [
'instance_id' => $instance_id,
'access_token' => $token,
'password_hash' => $password_hash,
'status' => 'active',
'created_by' => (int) $created_by,
]);
return ['instance_id' => $instance_id, 'access_token' => $token];
}
public function revoke_for_instance($instance_id) {
global $wpdb;
return $wpdb->delete($this->table, ['instance_id' => $instance_id], ['%d']);
}
}
The two-line revoke_for_instance gets called from exactly one place — the form-completion handler:
class FM_Form_Instance {
public function complete($instance_id) {
// ... validate, render PDF, upload to cloud ...
$wpdb->update(
$this->table,
['status' => 'abgeschlossen'],
['id' => $instance_id]
);
// Self-revoke: the guest link dies with the draft.
FM_Guest_Access::instance()->revoke_for_instance($instance_id);
}
}
That's the whole pattern. The status transition entwurf → abgeschlossen is the trigger. No cron, no TTL counter, no hunt for expired records.
What this buys you
No background processes. The plugin works on hosting where WP-Cron is unreliable.
No "stale token" class of bug. A token can't outlive the report it was issued for, because completing the report destroys the token in the same transaction.
Cleaner mental model. When I look at the table six months later, every active record corresponds to a report that's still being worked on. There's no archive of dead tokens to forget about.
The auth flow itself
For completeness, here's how the guest actually logs in. The URL has the token as a query parameter, the password is entered manually:
https://example.com/?fm_project_slug=abc&fm_guest_token=<40 char string>
public function authenticate($token, $password) {
global $wpdb;
// JOIN to also check the parent draft is still active.
$row = $wpdb->get_row($wpdb->prepare(
"SELECT g.*, i.status AS instance_status
FROM {$this->table} g
JOIN {$this->instances_table} i ON i.id = g.instance_id
WHERE g.access_token = %s
AND g.status = 'active'
AND i.status = 'entwurf'
LIMIT 1",
$token
), ARRAY_A);
if (!$row) return false;
if (!wp_check_password($password, $row['password_hash'])) return false;
return $row;
}
The JOIN is the safety net. Even if revocation somehow failed (DB error, bug, manual edit), the parent's status check still gates access.
When this pattern fits
This works when access is scoped to a single bounded resource with a clear lifecycle event. Reports, support tickets, draft invoices, signature requests — anything that goes through a discrete completion step.
It doesn't fit when access is open-ended (a guest seat on a team workspace, for example) or when the access object outlives its triggering event. For those, you're back to TTL or revocation lists.
But for the common case — "let someone do one thing, then forget them" — coupling token death to domain event is dramatically cleaner than running a janitor.
You can read the full case study (German) here: hafenpixel.de/hinter-den-kulissen/artenschutz-projektmanager-wordpress
English: hafenpixel.de/en/behind-the-scenes/species-protection-project-manager-wordpress
Top comments (0)