---
title: 'Authentication and Authorization'
source: 'https://academia.sh/en/courses/web-application-framework/authentication-and-authorization'
course: 'The Web Application Framework'
language: en
updated: '2026-10-21T05:03:38+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Separates authentication from authorization and shows at which step each one runs'
    - 'Measures how the gate skips the action and returns 403 according to the policy result'
    - 'Measures what the gate returns for an undefined ability and explains why denial should be the default'
---

# Authentication and Authorization

Storing and verifying a password as a hash, the user in the session, the gate and the policy; a legitimate user asking to edit someone else's post, the gate returning 403 and the action being skipped; the principle that an undefined ability is denied.

The Pagination and Resource Transformation lesson showed a new field that was not wired into the response: the model had changed, the response returned 200, but the change never reached the response body. This lesson opens the application services, and its question is: **can the person making the request do this work, and if not, did the action run?**

There are two separate questions, and frameworks place them in separate steps. **Authentication** establishes who is making the request: the username and password are correct, the session is tied to a user. **Authorization** decides whether the user whose identity is known may do this work. The first redirects to the `/login` page with 302; the second refuses with 403. Having authenticated someone does not give that person the right to edit every record.

**AS1.** The model uses no product name and does not read passwords from outside: a password is hashed with `password_hash()` and verified with `password_verify()`; the hash itself is never printed, because it produces a different value on every call. The session is a reduced form of the `session` property of the core's `Request` class: in this lesson the property carries the identity of the user in the session, and no `session_*` function is called. Password cracking, session theft, token forgery and privilege escalation are not modeled; the code that runs stays on the defensive side.

## The Measurement Core

The same measurement is used in every lesson of the course. `ran.php` records whether an expected part ran in this request; the file is given verbatim in every lesson, and it has no output block, since it is only saved to disk. `Ledger` keeps three lists: the parts placed on the request's path, the parts called within the request, and the parts left outside the request. `classify()` checks them in order. **The decision order is `classify()` itself:** ran, deferred, skipped, not wired. The developer names the part; what is measured are the parts the developer expects to run in this request.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

This lesson connects to the core with three calls: `Ledger::onPath()` when it places a part on the path and `Ledger::ran()` when it calls the part; a deferred part does not appear in this lesson. The whole framework is not rebuilt; only this lesson's slice is written.

## The Password Hash

The first step of authentication is the way a password is stored. A password is not stored as it is; `password_hash()` adds a salt to it and produces a one-way hash, and `password_verify()` compares a given password with the hash. The salt is new on every call, so two hashes of the same password differ but both verify. The measurement below does not print the hash itself, only the results of the comparisons.

```php
<?php
// password.php — two hashes of the same password
$first = password_hash('password-alice', PASSWORD_DEFAULT);
$second = password_hash('password-alice', PASSWORD_DEFAULT);
echo 'are the two hashes equal: ', $first === $second ? 'yes' : 'no', "\n";
echo 'does the first hash verify: ', password_verify('password-alice', $first) ? 'yes' : 'no', "\n";
echo 'does the second hash verify: ', password_verify('password-alice', $second) ? 'yes' : 'no', "\n";
echo 'does a wrong password verify: ', password_verify('password-bob', $first) ? 'yes' : 'no', "\n";
```

```
are the two hashes equal: no
does the first hash verify: yes
does the second hash verify: yes
does a wrong password verify: no
```

That the hashes are not equal says the comparison has to be made with `password_verify()`, not with `===`. For this reason `IdentityStore::login()` checks the username by equality and the password with the verification function. The store holds only the `passwordHash` property; the plain password is kept in no object.

## The Gate and the Policy

`authorization.php` is the whole slice and has four parts. `IdentityStore` keeps the password as a hash and verifies it with `login()`. The **gate**, that is `Gate`, binds the name of an ability (`post.edit`) to a **policy**; a policy is a small function that takes the user and the target object and returns true or false. `PostPolicy::edit()` states the rule in one line: only the owner edits the post.

`PostApp::edit()` places three expected parts on the path: `identity` (finding the user in the session), `gate` (the permission question) and `edit` (the action that changes the title). Each step is marked with `Ledger::ran()` when it is called. If there is no identity, the request returns with 302; if the gate refuses, it returns with 403, and the action is never called.

```php
<?php
// authorization.php — authentication, the gate and the policy
declare(strict_types=1);
require_once 'ran.php';

final class User {
    public function __construct(public int $id, public string $name, public string $passwordHash) {}
}

final class Post {
    public function __construct(public int $id, public int $ownerId, public string $title) {}
}

final class Request {
    public function __construct(public ?int $session = null, public array $body = []) {}
}

final class IdentityStore {
    private array $users = [];
    public function register(int $id, string $name, string $password): void {
        $this->users[$id] = new User($id, $name, password_hash($password, PASSWORD_DEFAULT));
    }
    public function login(string $name, string $password): ?User {
        foreach ($this->users as $user) {
            if ($user->name === $name && password_verify($password, $user->passwordHash)) return $user;
        }
        return null;
    }
    public function find(int $id): ?User { return $this->users[$id] ?? null; }
}

final class Gate {
    private array $policies = [];
    public function __construct(private bool $undefinedPasses = false) {}
    public function define(string $ability, Closure $policy): void { $this->policies[$ability] = $policy; }
    public function allows(string $ability, User $user, object $target): bool {
        if (!isset($this->policies[$ability])) return $this->undefinedPasses;
        return ($this->policies[$ability])($user, $target);
    }
}

final class PostPolicy {
    public static function edit(User $user, Post $post): bool {
        return $user->id === $post->ownerId;
    }
}

final class PostApp {
    public function __construct(private IdentityStore $identity, private Gate $gate, public array $posts) {}
    public function edit(Request $request, int $postId, string $ability = 'post.edit'): array {
        Ledger::newRequest();
        foreach (['identity', 'gate', 'edit'] as $part) Ledger::onPath($part);
        Ledger::ran('identity');
        $user = $request->session === null ? null : $this->identity->find($request->session);
        if ($user === null) return [302, '/login'];
        Ledger::ran('gate');
        $post = $this->posts[$postId];
        if (!$this->gate->allows($ability, $user, $post)) return [403, 'not allowed'];
        Ledger::ran('edit');
        $post->title = $request->body['title'];
        return [200, 'updated'];
    }
}
```

```php
<?php
// gate.php — two users, one post, three requests
require_once 'authorization.php';

$identity = new IdentityStore();
$identity->register(1, 'alice', 'password-alice');
$identity->register(2, 'bob', 'password-bob');
echo 'login with correct password: ', $identity->login('bob', 'password-bob')?->name ?? 'none', "\n";
echo 'login with wrong password: ', $identity->login('bob', 'password-alice')?->name ?? 'none', "\n";

$gate = new Gate();
$gate->define('post.edit', PostPolicy::edit(...));
$app = new PostApp($identity, $gate, [10 => new Post(10, 1, 'Hello')]);

function measure(string $name, array $response): void {
    printf("%s -> %d %s\n", $name, $response[0], $response[1]);
    foreach (['identity', 'gate', 'edit'] as $part) printf("  %-8s %s\n", $part, classify($part));
}

measure('no session', $app->edit(new Request(null, ['title' => 'New']), 10));
measure('bob', $app->edit(new Request(2, ['title' => 'New']), 10));
measure('alice', $app->edit(new Request(1, ['title' => 'New']), 10));
echo 'title: ', $app->posts[10]->title, "\n";
```

```
login with correct password: bob
login with wrong password: none
no session -> 302 /login
  identity ran
  gate     skipped
  edit     skipped
bob -> 403 not allowed
  identity ran
  gate     ran
  edit     skipped
alice -> 200 updated
  identity ran
  gate     ran
  edit     ran
title: New
```

The first lines are the `password_verify()` results: the correct password returns the user, and another person's password does not. The real observation is in the request lines. In the request without a session, `identity` ran, the gate and the action were on the path but were not called: both were **skipped**, and the status code is 302. The second request is this lesson's observation: Bob is logged in, authenticated, a legitimate user, and he wants to edit Alice's post. `identity` and `gate` ran, the gate executed the policy, the policy returned false, the status became 403, and `edit` was **skipped**. That the title on the last line is `New` and not `Hello` says the change came only from the third request.

A skipped part gives itself away through the status code. Seeing 403 in Bob's request explains why the action did not run; no error was thrown, because a refusal is an expected outcome.

## The Contrast: The Post's Owner

The third request goes through the same code path, and the only difference is that the user is the owner of the post. The policy returns true, the gate lets the request through, `edit` **ran**, and the status is 200. Nothing changed in the gate; what changed is that the user given to the policy matches the `ownerId` property of the post. The gate is not an obstacle but a decision, and the decision is made again in every request.

A distinction is needed here. `identity` ran in all three requests: whether authentication succeeded or failed, the step was called. The gate being skipped in the request without a session and the action being skipped in Bob's request have different causes, but the outcome is the same: the path existed, and a step before the part gave the response early. It is `classify()`, not the cause, that determines the outcome.

## The Bounding Measurement

What happens if the gate is asked about an ability that was never defined? In real applications this happens through a typo: the policy is bound to the name `post.edit`, and the action asks for `post.edti`. The model builds two gates: one denies the undefined ability (the `Gate` default), the other lets it pass (`undefinedPasses: true`). The same misspelled ability name is asked for both the owner of the post and a user who is not the owner.

```php
<?php
// undefined.php — the gate is asked about an undefined ability
require_once 'authorization.php';

function build(bool $undefinedPasses): PostApp {
    $identity = new IdentityStore();
    $identity->register(1, 'alice', 'password-alice');
    $identity->register(2, 'bob', 'password-bob');
    $gate = new Gate($undefinedPasses);
    $gate->define('post.edit', PostPolicy::edit(...));
    return new PostApp($identity, $gate, [10 => new Post(10, 1, 'Hello')]);
}

foreach ([false, true] as $passes) {
    foreach ([[1, 'alice'], [2, 'bob']] as [$session, $name]) {
        $app = build($passes);
        $response = $app->edit(new Request($session, ['title' => 'New']), 10, 'post.edti');
        printf("%-10s %-6s -> %d  gate %-8s edit %s\n", $passes ? 'passing' : 'rejecting', $name,
            $response[0], classify('gate'), classify('edit'));
    }
}
```

```
rejecting  alice  -> 403  gate ran      edit skipped
rejecting  bob    -> 403  gate ran      edit skipped
passing    alice  -> 200  gate ran      edit ran
passing    bob    -> 200  gate ran      edit ran
```

The result has two sides. With the rejecting gate, even the owner of the post gets 403: the failure is visible loudly, and the developer notices the name mismatch on the first attempt. With the passing gate, Bob's request returns 200 and the action runs; the gate ran, but no policy executed, so treating the presence of a gate as the same thing as protection is misleading. The principle **an undefined ability must be denied** follows from this: answering "yes" to an unknown question means the protection is silently lifted, and the response leaves no trace of it.

## Summary

- This lesson's outcome is **skipped**: in Bob's request `identity` and `gate` ran, `edit` was on the path, but the gate returned 403 and the action was not called.
- Authentication establishes who someone is, authorization establishes what that person may do; the first shows itself with 302, the second with 403.
- Its contrast: for the post's owner the policy returned true, `edit` **ran**, and the status was 200.
- In the bounding measurement, with the rejecting gate both users received 403 for the undefined ability; with the passing gate both received 200 and the action ran. An undefined ability must be denied.
- A password is stored with `password_hash()` and verified with `password_verify()`; the hash is not written to the output.

## Next Step

When the gate refused, the action was skipped and the response returned at once. But if the action will run and a job inside it will delay the response, does that job have to make the response wait? The Queues and Jobs lesson leaves a long job outside the request and measures that the response does not wait for it.
