Jul 15, 2026

Encrypted Form Tokens: Hiding Server-Side Dispatch from the HTML

Most CSRF tokens only prove a request came from your form. This article explains how to take form tokens further: they can carry the processor class and method to run, so the URL becomes a hint and the token becomes the source of truth.


Introduction

A standard CSRF token proves one thing: that a request came from your form and not from someone else’s page. It is a random value the server handed out and expects back. That is useful, but it is also where most setups stop. The request still tells the server what to do through the URL, the form action, and the parameter names, and the server trusts that routing.

While building LinkRail’s form handling, I wanted to close that last gap. Instead of letting the browser name the server-side code path and then validating it, I decided the token itself should carry the instruction for what should run. The processor class and the method live encrypted inside the form token. The form action’s URL you can see in the HTML is only a hint, and the encrypted token is the source of truth. The two have to agree, and the client never gets to name a code path the server will trust on its own.

Requirements

When designing this system, I had some requirements for myself:

  1. The URL never decides what runs - the encrypted token carries the processor class and method, and the visible action path is only a hint that has to agree with it.
  2. Tokens cannot be forged or tampered with - the token has to be authenticated, not just encrypted, so a fabricated or modified token dies before any dispatch happens.
  3. Dispatch is allow-listed - even a valid token can only resolve to a known set of processor classes; request text never becomes a class name.
  4. The processor re-validates at the point of use - after the router says yes, the processor still confirms the right user submitted the right form with the expected parameters.

What the browser actually sees

Every form in the app posts to a path like /process/projects/create/ and carries an encrypted hidden field. In view source, that is all an attacker gets:

<form action="/process/projects/create/" method="post" id="create_form">
  <input type="hidden" name="form_token"
         value="hT9k1Zr...a-long-opaque-base64url-blob...Qw2">
  <!-- fields -->
  <button type="submit" name="create">Create Project</button>
</form>

There is nothing authoritative to grab onto here. The projects/create in the path looks like an obvious thing to tamper with. What happens if I change it to /process/settings/update/? What if I point it at a method I am not supposed to call? The form_token blob is AES-256-GCM ciphertext, so it is not something you can read or edit. And as we will see, the path itself does not decide anything on its own.

Putting the instruction inside the token

When I build a form, I pass the dispatch instruction into the token as part of its data. The Form() constructor always appends logged in user id and form id to the token. Here is the token for the login form:

<?php
$form = new Form(
    new FormOptions(
        action: '/process/login/',
        method: 'post',
        id:  'login_form',
        token: ['processor' => [ Processors::login, 'login' ]]
    )
);

That 'processor' => [ Processors::login, 'login' ] is the whole idea. The first element is a Processors enum case, and the second is the method name to call. This array, not the URL, is what actually decides which code runs. Processors::login is a backed enum whose value is the real class name:

<?php
enum Processors : string
{
    // ... snippet of values
    case login = 'ProcessLogin';
    case projects = 'ProcessProjects';
    // ...and so on
}

Using an enum here matters, and I will come back to why. For now the important part is that the instruction is data that goes into the token before it is encrypted.

Encrypting and authenticating the token

The token array is serialized and encrypted with AES-256-GCM. I picked GCM deliberately, because it gives me two properties at once: secrecy and integrity.

<?php
public static function encrypt(array | string $data): string
{
    global $app;

    if (is_array($data)) {
        $data[$app->form_token_1] = bin2hex(random_bytes(32));
        $data[$app->form_token_2] = bin2hex(random_bytes(32));
        $data['time'] = $app->current_time;
        $data = serialize($data);
    }

    // Derive an encryption key from the app's configuration.
    $encryption_key = self::deriveKey();

    $iv = random_bytes(12);

    $ciphertext = openssl_encrypt(
        $data,
        'aes-256-gcm',
        $encryption_key,
        OPENSSL_RAW_DATA,
        $iv,
        $tag
    );

    return self::base64UrlEncode(implode('::', [
        bin2hex($iv),
        bin2hex($tag),
        bin2hex($ciphertext)
    ]));
}

A few things are happening before encryption. Two random 32-byte values and a timestamp get added to the array, which gives every token a fresh fingerprint and a time for expiration checks. The key is derived from the application configuration, and a fresh 12-byte IV is generated per call so identical data never produces identical ciphertext.

The GCM authentication $tag is the part that makes this more than obfuscation. It gets stored right alongside the IV and ciphertext, and on the way back in it has to verify. If an attacker flips a single byte trying to nudge the decrypted processor toward something else, the tag no longer matches and decryption fails outright:

<?php
$decrypted = openssl_decrypt(
    $ciphertext,
    'aes-256-gcm',
    $encryption_key,
    OPENSSL_RAW_DATA,
    $iv,
    $tag
);

if ($decrypted === false) {
    return [];
}

So there is no useful way to tamper with an existing token, and no way to forge a new one without the application’s configuration. A returned empty array means the request is dead on arrival.

Dispatching from the token, not the URL

Here is where it all comes together. Every POST to a /process/... route runs through RouterRequest::post(), and this is the piece that decides what actually executes. I have simplified some of the following and pulled out a couple of framework-specific details so the security logic reads on its own:

<?php
public static function post()
{
    global $app;

    // URLs look like /process/{processor}/{method}/, but the path is only a hint.
    [$url_processor, $url_method] = self::routeSegments();

    if (empty($_POST['form_token'])) {
        // router go 404 exits
        Router::go404();
    }

    // The encrypted token is the real instruction. If it is missing, tampered
    // with, or forged, decrypt() returns nothing and the request dies here.
    $token = Crypt::decrypt($app->request->post['form_token']);
    if (empty($token)) {
        Router::go404();
    }

    // The token authorizes exactly one processor and one method.
    [$authorized_processor, $authorized_method] = $token['processor'];

    // The processor named in the URL must resolve to a known enum case and
    // match what the token authorized. Rewriting the URL gets you nowhere.
    // (tryFromName() is a custom helper that looks up a case by name.)
    $requested_processor = Processors::tryFromName($url_processor);
    if ($requested_processor !== $authorized_processor) {
        Router::go404();
    }

    // If the URL names a method it may only agree with the token. The method
    // that actually runs always comes from the token, never the URL.
    if ($url_method && $url_method !== $authorized_method) {
        Router::go404();
    }

    // Build the class name from the enum's own value, never from request text.
    $class = $app->namespace . '\\' . $authorized_processor->value;
    if (!class_exists($class) || !method_exists($class, $authorized_method)) {
        Router::go404();
    }

    $processor = new $class();
    if (!$processor->valid()) {
        Router::go404();
    }

    $processor->{$authorized_method}();
}

Walk through what an attacker can and cannot do here.

First, the token is decrypted. If it is missing, tampered, or forged, decrypt() returns an empty array and the request 404s before anything else happens.

Next, the processor named in the URL is resolved through the Processors enum and compared to the processor stored in the token. This is the key line: if ($requested_processor !== $authorized_processor). If someone rewrites the action from /process/projects/create/ to /process/settings/update/, the URL resolves to Processors::settings while the token still says Processors::projects. They do not match, so the request 404s. The URL cannot point the dispatch anywhere the token did not already authorize.

The method is handled the same way. If the URL includes a method segment, it is only allowed to be a consistency check. The method that actually gets called is $authorized_method, pulled straight from the encrypted token, never from the URL. Swapping the URL method to something dangerous does not work, because a URL method that disagrees with the token 404s, and even if it did agree, it is not the value that gets invoked.

Finally, notice how the class name is built. The namespace is fixed in code, and the class name is the enum case’s backing value, not raw text from the request. There is no path here where user input becomes a class name. The class_exists and method_exists guards, plus a valid() check on the instantiated processor, are the last belt-and-suspenders step before the method is called.

Why this holds up

It is worth listing the layers out, because the strength is in the stack, not any one piece.

  • You cannot forge a token. It is AES-256-GCM encrypted with a key derived from the app’s configuration. Without the configuration, you cannot produce ciphertext that decrypts to anything at all.
  • You cannot tamper with a token. The GCM authentication tag has to verify. Any change to the bytes makes decryption fail.
  • Even a valid token is constrained to an allow-list. The decrypted processor is a Processors enum case, and dispatch only ever resolves to one of those known classes. There is no way to reach an arbitrary class, because the enum is the only vocabulary the router speaks.
  • The URL is never trusted on its own. It has to agree with the token, and the token wins.

That combination is what takes this past “validate a random token.” The token is not just proof of origin. It is a sealed, signed instruction that says exactly which handler and method are allowed to run for this submission.

The processor still checks the submission

Getting dispatched to the right method is not the end of validation. Every processor method starts by calling checkFormSubmit(), which re-validates the submission against the token at the point of use. From inside a processor it reads like this:

<?php
class ProcessProjects extends Processor
{
    // ... example method.  this could be create, update, delete, etc.
    public function create()
    {
        if ($this->checkFormSubmit(token_keys: ['form', 'team_id'])) {
            // safe to create the project
        }
    }
}

The internals for checkFormSubmit() are ordinary application plumbing, so rather than paste them I will just say what the method guarantees. Before a processor method is allowed to do any work, checkFormSubmit() confirms that:

  • the request actually targets the method it claims to, by matching form elements against the token
  • the user recorded in the token is the same user who is currently logged in; unless the form is one of the few that explicitly bypasses the login check (login, register, forgot password)
  • every incoming parameter name appears in the processor’s whitelist

If any of those fail, the request is either sent to a 404 or the session is logged out and bounced back to login. The router decides that the right handler may run, and the processor confirms that this particular user really submitted this particular form.

Conclusion

The mental shift here is small but it changes a lot. A CSRF token does not have to be an opaque random value that only proves origin. It can carry the instruction for what should happen, sealed so the client cannot read or change it. Looking back at the requirements I set out at the start:

  1. The URL never decides what runs - The visible request is a hint and the encrypted token is the truth. When they disagree, the token wins, and usually that means a 404.
  2. Tokens cannot be forged or tampered with - AES-256-GCM is authenticated encryption, not just encryption, so tamper detection comes for free. That is what makes the token trustworthy rather than merely hidden.
  3. Dispatch is allow-listed - The decrypted instruction resolves to an enum, and class names are built from the enum’s own values, never from request text.
  4. The processor re-validates at the point of use - The router decides that the right handler may run, and checkFormSubmit() confirms that this particular user really submitted this particular form. Any one layer can be the one that holds.

None of these pieces are exotic on their own. Putting the dispatch instruction inside the token is what ties them together, and it is a surprisingly small amount of code for how much it takes off the table.


✌️ Matt