Anyone on the internet can browse mugs. Creating products and placing orders should only work for someone the shop recognizes — Ada after she logs in, or a staff account that manages the catalog.
This step teaches what “authentication” means in any web app, then wires it for Pionia Shop with a JWT.
What is authentication? (in general)
Two related ideas get mixed up. Keep them separate:
| Word | Plain English | Shop example |
|---|---|---|
| Authentication | Who are you? | Ada proves email + password |
| Authorization | What may you do? | Staff may product.create; Ada may order.place |
Without authentication, every request is anonymous. Your API cannot tell “Ada” from “a random bot.”
Sessions vs tokens (big picture)
Browsers historically used cookies + server sessions: “remember this browser.” Mobile apps and SPAs often use tokens: after login, the server returns a string; the client sends it on later calls as Authorization: Bearer ….
Pionia Shop uses the token style with a JWT (JSON Web Token): a signed blob that carries Ada’s id (and optional permissions) so the server does not need to look up a session row on every request.
1. Ada → customer.login (email + password)
2. API → { "token": "eyJ…" }
3. Ada → order.place + header Authorization: Bearer eyJ…
4. API verifies signature → knows it is Ada → runs the actionIf the token is missing or invalid, protected actions return 401. If the token is fine but she lacks a permission, you return 403 (authorization).
How Pionia Shop uses it
- Configure a signing secret (
JWT_SECRET). - Register
JwtAuthenticationso Bearer tokens are read automatically. - Implement
customer.loginto issue a token withjwt_encode(). - Mark write actions with
#[Can('product.manage')]for staff-only catalog writes.
What you will learn
- Configure
JWT_SECRETand registerJwtAuthentication - Implement
customer.loginwithjwt_encode() - Protect
product.createwhile leavingproduct.listpublic
Configure JWT
Step 1 Step
1. Secret in .env
In environment/.env:
JWT_SECRET=paste-a-long-random-value
JWT_TTL=3600Generate a secret in the shell:
php pionia shell
# then: secure_random_hex(32);Never commit a real secret. Local .env stays on your machine.
Step 1 Step
2. Register the authentication backend
In environment/settings.ini:
[app_authentications]
jwt = "Pionia\Auth\JwtAuthentication"This tells Pionia: “on each request, look for a Bearer JWT and attach the user when it is valid.”
Create two local accounts
Create a table with a server-controlled role. A shopper can place orders; staff can also manage the catalog.
php pionia make:table customers --columns="email:string:unique,password_hash:string,role:string"
php pionia migrate
php pionia shellRun each of these lines in the Pionia shell, then type exit. These accounts are for your local tutorial database only:
table('customers')->save(['email' => 'ada@pionia.shop', 'password_hash' => hash_password('shopper-local-demo'), 'role' => 'customer']);
table('customers')->save(['email' => 'staff@pionia.shop', 'password_hash' => hash_password('staff-local-demo'), 'role' => 'staff']);Run the inserts once: email is unique. A future registration action should always assign customer on the server. Never accept a role or permission list from a registration or login payload.
CustomerService login
php pionia make:service CustomerChoose Basic when prompted. Replace the generated services/CustomerService.php with:
<?php
namespace Application\Services;
use Pionia\Auth\Attributes\Authenticated;
use Pionia\Collections\Arrayable;
use Pionia\Exceptions\UserUnauthenticatedException;
use Pionia\Http\Response\ApiResponse;
use Pionia\Http\Services\Service;
use Pionia\Validations\Attributes\Validated;
#[Authenticated(except: ['login'])]
class CustomerService extends Service
{
#[Validated(rules: ['email' => 'required|email', 'password' => 'required|string'])]
protected function loginAction(Arrayable $data): ApiResponse
{
$customer = table('customers')
->filter(['email' => $data->get('email')])
->get();
if (!$customer || !verify_password($data->get('password'), $customer->password_hash)) {
throw new UserUnauthenticatedException('Invalid credentials');
}
$permissions = $customer->role === 'staff'
? ['product.manage', 'order.place']
: ['order.place'];
$token = jwt_encode([
'sub' => $customer->id,
'email' => $customer->email,
'permissions' => $permissions,
]);
return response(0, 'Logged in', ['token' => $token]);
}
}Add use Application\Services\CustomerService; to switches/MainSwitch.php, then add 'customer' => CustomerService::class to the array returned by registerServices().
except: ['login'] keeps login public. Throwing UserUnauthenticatedException produces HTTP 401; response(401, ...) alone would only set the envelope’s returnCode. JWT permissions come from the stored role, not the request.
Protect catalog writes
Keep the validation from Step 8 and require the catalog permission. Add these imports once, above the ProductService class:
use Pionia\Auth\Attributes\Can;
use Pionia\Validations\Attributes\Validated;Replace createAction with:
#[Can('product.manage')]
#[Validated(rules: [
'name' => 'required|string|min:2',
'price' => 'required|numeric',
'stock' => 'integer|min:0',
])]
protected function createAction(Arrayable $data): ApiResponse
{
$product = table('products')->save([
'name' => $data->get('name'),
'price' => $data->get('price'),
'stock' => $data->getInt('stock') ?? 0,
]);
return response(0, 'Product created', ['id' => $product->id]);
}#[Can] also requires authentication: no token produces HTTP 401; a shopper token without product.manage produces HTTP 403.
Leave listAction without the attribute so guests can still browse.
Try the flow
# 1) log in as staff
curl -i -X POST http://127.0.0.1:8000/api/v1/ \
-H "Content-Type: application/json" \
-d '{"service":"customer","action":"login","email":"staff@pionia.shop","password":"staff-local-demo"}'
# 2) create with token (paste token from step 1)
curl -i -X POST http://127.0.0.1:8000/api/v1/ \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"service":"product","action":"create","name":"Cap","price":15,"stock":20}'Try create without the header — you should get HTTP 401. Then log in with ada@pionia.shop and shopper-local-demo, and repeat create with Ada’s token — expect HTTP 403. The staff token should succeed with returnCode: 0 and a scalar returnData.id.
Permissions are captured when the token is issued. Changing a stored role does not rewrite existing tokens; sign in again to test role changes. Plan token expiry or revocation before using this pattern in production.
More depth: JWT authentication and Protecting actions.
Common mistakes
- Protecting
loginby accident — keep it inexcept - Forgetting
[app_authentications]— tokens never attach a user - Putting the password in the JWT — never; only ids / permissions
- Confusing 401 (not logged in) with 403 (logged in but not allowed)