Users & Security
CWA uses cookie-based JWT authentication. The API sets a secure HttpOnly cookie on login; the Nuxt module reads auth state from the JWT payload. No Authorization headers, no manual token storage — the browser handles it transparently.
The Authentication Flow
- Client sends credentials to
POST /login - API validates, issues a JWT and a refresh token — both as HttpOnly cookies
- Every subsequent request sends the cookies automatically (same-origin or configured CORS)
- When the JWT expires, the API auto-refreshes it using the refresh token cookie
- The client never sees or stores the raw token values
AbstractUser
Your User entity (created by the Flex recipe) extends AbstractUser:
// src/Entity/User.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Silverback\ApiComponentsBundle\Annotation as Silverback;
use Silverback\ApiComponentsBundle\Entity\User\AbstractUser;
#[ORM\Entity]
#[ApiResource(
operations: [/* restrict to ROLE_SUPER_ADMIN */]
)]
class User extends AbstractUser
{
// Add custom fields here
}
What AbstractUser Provides
| Field | Serialization group | Notes |
|---|---|---|
username | User:output | Used as login identifier |
emailAddress | User:output | Unique; separate from username |
roles | User:superAdmin | Array; ROLE_USER always included |
enabled | User:superAdmin | Disabled users cannot log in |
plainPassword | User:input (write-only) | Hashed before persist |
emailAddressVerified | User:output | Set by email verification flow |
newEmailAddress | User:output | Pending email change |
Passwords are never serialized to output — readable: false is set on the hashed password field.
Generating JWT Keys
One-time setup per environment:
mkdir -p config/jwt
openssl genpkey -out config/jwt/private.pem -aes256 -algorithm rsa -pkeyopt rsa_keygen_bits:4096
openssl pkey -in config/jwt/private.pem -out config/jwt/public.pem -pubout
Add to .env.local (never commit):
JWT_PASSPHRASE=your_secure_passphrase
Add to .env:
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
lexik_jwt_authentication Configuration
The JWT cookie name must match the refresh token cookie name in the bundle config:
# config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: 3600
set_cookies:
api_components:
lifetime: 604800 # 1 week
samesite: '%env(JWT_COOKIE_SAMESITE)%'
secure: true
httpOnly: true
Refresh Token Configuration
silverback_api_components:
refresh_token:
handler_id: silverback.api_components.refresh_token.storage.doctrine
options:
class: App\Entity\RefreshToken
cookie_name: api_components # must match lexik_jwt cookie name
ttl: 604800 # 1 week
database_user_provider: database
Security Configuration
The Flex recipe provides a working security.yaml. Key sections:
security:
role_hierarchy:
ROLE_ADMIN: ROLE_USER
ROLE_SUPER_ADMIN: ROLE_ADMIN
firewalls:
main:
stateless: true
provider: database
guard:
authenticators:
- lexik_jwt_authentication.jwt_token_authenticator
access_control:
# Anonymous access for auth flows
- { path: '^/login', roles: PUBLIC_ACCESS }
- { path: '^/token/refresh', roles: PUBLIC_ACCESS }
- { path: '^/password/reset', roles: PUBLIC_ACCESS }
- { path: '^/password/update', roles: PUBLIC_ACCESS }
# Allow anonymous form submissions (contact forms etc.)
- { path: '^/component/forms/.*/submit', methods: ['POST', 'PATCH'], roles: PUBLIC_ACCESS }
# Public read access
- { path: '^/', methods: ['GET'], roles: PUBLIC_ACCESS }
# Mutations require authentication
- { path: '^/', roles: ROLE_USER }
Email Verification Flow
Configure verification behaviour in silverback_api_components.yaml:
silverback_api_components:
user:
class_name: App\Entity\User
email_verification:
default_value: false # new users start unverified
verify_on_register: true # send verification email on POST /users
verify_on_change: true # re-verify when email changes
deny_unverified_login: true # block unverified users from logging in
email:
redirect_path_query: null
default_redirect_path: /verify-email/{{ username }}/{{ token }}
subject: Please verify your email
The Nuxt module provides the /verify-email/[username]/[token] page automatically.
Password Reset Flow
silverback_api_components:
user:
password_reset:
email:
redirect_path_query: null
default_redirect_path: /reset-password/{{ username }}/{{ token }}
subject: Your password reset request
repeat_ttl_seconds: 8600 # minimum time between reset requests
request_timeout_seconds: 3600 # token validity window
The Nuxt module provides /forgot-password and /reset-password/[username]/[token] automatically.
Email Address Change Flow
silverback_api_components:
user:
new_email_confirmation:
email:
redirect_path_query: null
default_redirect_path: /confirm-new-email/{{ username }}/{{ new_email }}/{{ token }}
subject: Please confirm your new email address
request_timeout_seconds: 86400
Notification Emails
Configure which system emails are sent and their subjects:
silverback_api_components:
user:
emails:
welcome:
enabled: true
subject: 'Welcome to {{ website_name }}'
user_enabled:
enabled: true
subject: 'Your account has been enabled'
username_changed:
enabled: true
subject: 'Your username has been updated'
password_changed:
enabled: true
subject: 'Your password has been changed'
Set MAILER_DSN in your environment:
MAILER_DSN=smtp://user:pass@smtp.example.com:587
Route Security
Restrict which routes are visible in the API based on the current user's role:
silverback_api_components:
route_security:
- { route: '/user-area*', security: "is_granted('ROLE_USER')" }
- { route: '/admin*', security: "is_granted('ROLE_ADMIN')" }
route_security accepts any number of patterns. Each route value supports a * wildcard that matches any sequence of characters (internally converted to a regex — * does not need to be a %-style SQL wildcard). The security value is any Symfony expression-language security expression.
Two things happen for each rule:
- Collection filtering — the
Routecollection endpoint (GET /_/routes) omits routes matching the pattern when the expression is not satisfied. Anonymous users won't see/admin/*routes at all. - Item access — fetching a specific
Routeby IRI is denied if any matching rule's expression fails.
routable_security
Controls who can read un-routed pages and page data, and who can create new pages and page data:
silverback_api_components:
routable_security: "is_granted('ROLE_ADMIN')"
Without this, every Page and PageData record is visible in API collections regardless of whether it has a public route. This matters for template pages (isTemplate: true) and draft pages — they exist in the database but have no URL.
When routable_security is set:
GET (read access)
- Users who pass the expression see all records (including un-routed ones).
- Users who fail the expression only see records that have a
routeassigned — i.e., publicly accessible pages.
This prevents anonymous users from discovering admin-only templates or draft content through the collection endpoints.
POST (create access)
Creating a new Page or PageData resource requires the routable_security expression to pass. This matches the existing restriction on edit operations — both creating and modifying CMS structure require admin access.
Component Security
Components are automatically secured based on the routes they are reachable through. You do not need to configure this — it is built into the bundle via ComponentVoter.
When a GET request is made for a specific component IRI, the bundle checks whether that component is accessible to the current user by:
- Route check — is the component part of a page that has a publicly accessible route? If yes, access is granted.
- PageData check — is the component referenced as a property on a
PageDataresource with a reachable route? If yes, access is granted. - Template check — is the component placed in a page template used by reachable page data? If yes, access is granted.
- If none of the above resolve (the component is genuinely unreachable for this user), access is denied.
This means components placed exclusively on admin pages are automatically hidden from anonymous API clients without any extra configuration.
Creating the First Admin User
bin/console silverback:api-components:user:create
You'll be prompted for username, email, and password. Without flags, the user is created with ROLE_USER only.
| Flag | Role granted |
|---|---|
| (none) | ROLE_USER |
--admin | ROLE_ADMIN |
--super-admin | ROLE_SUPER_ADMIN |
To create an admin non-interactively:
bin/console silverback:api-components:user:create alice alice@example.com s3cr3t --admin