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:output, User:superAdmin | Array. Expanded through role_hierarchy on output, so ROLE_USER is always present even though only the assigned role is stored |
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
Lexik needs to do two things: write the JWT into a cookie on login, and read it back out of that cookie on every subsequent request. Configure both — set_cookies alone leaves the app expecting an Authorization header:
# 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
token_extractors: authorization_header: enabled: true prefix: Bearer name: Authorization cookie: enabled: true name: api_componentslexik_jwt_authentication.set_cookies.<name>, lexik_jwt_authentication.token_extractors.cookie.name, and silverback_api_components.refresh_token.cookie_name. The template app ships api_component (singular) in all three — if you started from the template, keep its name rather than the one used in these examples.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. This is the shape the template app ships:
security:
role_hierarchy:
ROLE_ADMIN: ROLE_USER
ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
password_hashers:
Silverback\ApiComponentsBundle\Entity\User\AbstractUser:
algorithm: auto
providers:
database:
entity:
class: Silverback\ApiComponentsBundle\Entity\User\AbstractUser
jwt:
lexik_jwt:
class: App\Entity\User
jwt_database_chain:
chain:
providers: ['jwt', 'database']
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
# Handles POST /_api/login and issues the JWT + refresh token cookies
login:
pattern: ^/_api/login
stateless: true
provider: database
user_checker: Silverback\ApiComponentsBundle\Security\UserChecker
json_login:
check_path: /_api/login
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
# Everything else authenticates from the JWT cookie
main:
pattern: ^/
stateless: true
provider: jwt_database_chain
logout:
path: /_api/logout
jwt: ~
access_control:
- { path: ^/_api/token/refresh, roles: PUBLIC_ACCESS }
- { path: ^/_api/password/(reset|update), roles: PUBLIC_ACCESS, methods: [POST] }
# Allow anonymous form submissions (contact forms, password reset, etc.)
- { path: ^/_api/component/forms/(.*)/submit, roles: PUBLIC_ACCESS, methods: [POST, PATCH] }
# Reads stay public (individual resources are still secured by voters); writes need a user
- { path: ^/, roles: IS_AUTHENTICATED_FULLY, methods: [POST, PUT, PATCH, DELETE] }
^/_api prefix on those paths comes from config/routes/silverback_api_components.yaml, where the bundle's routing file is imported with prefix: /_api. If you mount the bundle's routes somewhere else, adjust the access_control paths to match.user_checker: Silverback\ApiComponentsBundle\Security\UserChecker is what enforces enabled: false and, when configured, deny_unverified_login.
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: 86400 # minimum time between reset requests (default)
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. Write them with a * wildcard; the security value is any Symfony expression-language security expression.
* differently. Collection filtering turns it into a SQL LIKE wildcard, which is anchored to the whole path. Item access turns it into a regex that is not anchored, so /admin* also denies a path such as /foo/admin/bar. Prefer patterns that start at the beginning of the path.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 at least one check found a location but none of them is reachable 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.
#[Silverback\Publishable] or your own security expression if a component must stay private before it is placed.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 | Effect |
|---|---|
| (none) | Roles set to ['ROLE_USER'] |
--admin | Roles set to ['ROLE_ADMIN'] |
--super-admin | Roles set to ['ROLE_SUPER_ADMIN'] |
--inactive | Creates the account disabled — it cannot log in until enabled is set |
--overwrite | Updates the existing user with that username instead of failing |
The role is set, not added: --admin stores ROLE_ADMIN on its own and picks up ROLE_USER through role_hierarchy. Users created this way are marked email-verified, so they can log in even with deny_unverified_login: true.
To create an admin non-interactively:
bin/console silverback:api-components:user:create alice alice@example.com s3cr3t --admin