DEV Community

Cover image for Self-Revoking Guest Tokens: A WordPress Pattern Without Cron
Joshua
Joshua

Posted on • Originally published at hafenpixel.de

Self-Revoking Guest Tokens: A WordPress Pattern Without Cron

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,
    ...
);
Enter fullscreen mode Exit fullscreen mode

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']);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode

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)