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/loginin the template, where the bundle's routes are mounted under/_api) - 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 extends AbstractUser (the template app's api/src/Entity/User.php is a working starting point):
// 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:input, User:output | Pending email change. Set it to null to cancel the 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
There is no working Flex recipe for the bundle (see Bundle Setup), so start from the template app's security.yaml. This is its shape:
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 }
# 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.
Links in Emails
Password reset, email verification, new-email confirmation and account-enabled emails carry links to your front end, and so does the welcome email when verify_on_register is on. The username-changed and password-changed emails have no links. user.email_links decides which origin those links use:
silverback_api_components:
user:
email_links:
default_origin: 'https://www.example.com' # scheme://host[:port]
allowed_origins: ['https://app\.example\.com'] # optional regexes
- The request's
Originheader (orRefererwhen there's noOrigin) is used only when it matches one ofallowed_origins. - Otherwise the link uses
default_origin. - With neither, the link is refused and the email isn't sent. See When a link is refused.
A single-host site only needs default_origin. The template app sets it from EMAIL_LINK_DEFAULT_ORIGIN, and falls back to https://<BROWSER_SERVER_NAME>. Add allowed_origins only when emails must link to more than one front end.
email_links.default_origin (or allowed_origins). Without it, every email that carries a link is refused, and password reset and resend-verification requests return 400.allowed_origins pattern to the whole origin and matches it case-insensitively. Nelmio's origin_regex does not anchor, so check a CORS_ALLOW_ORIGIN value before you copy it here. The origin being matched has no path and no trailing slash, and a default port is dropped (https://app.example.com, not https://app.example.com:443/). Only http and https origins are accepted.A path requested through redirect_path_query must be a plain relative path: it starts with a single / and has no //, backslash, space or control character. Anything else falls back to the flow's default_redirect_path. A default_redirect_path that is a full URL is used as it is and needs no origin.
When a link is refused
What happens depends on whether the email is the request's whole job:
| When the link is refused | |
|---|---|
| Password reset request, and both resend-verification endpoints | 400, and nothing changes. An existing reset token keeps working |
Emails after a completed write: welcome with its verification link (when verify_on_register is on), account enabled, and the verification or confirmation email after an email change | The write succeeds with its normal status. The email isn't sent, and the refusal is logged at error level |
So a misconfigured site shows up in the error log, not as a failed registration. From 2.0.0-alpha.5 on. In 2.0.0-alpha.4, registration and the new-email form returned 400 even though the user had been saved.
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
repeat_ttl_seconds: 300 # minimum time between verification emails (default)
The Nuxt module provides the /verify-email/[username]/[token] page automatically.
Verification endpoints
The bundle registers these under your API prefix. All are GET:
| Route | Purpose |
|---|---|
/verify-email/{username}/{token} | Verify the address on registration |
/resend-verify-email/{username} | Resend verification for the current address |
/confirm-email/{username}/{emailAddress}/{token} | Confirm a changed address |
/resend-verify-new-email/{username} | Resend verification for a pending address change |
The two resend routes take a username and no token — the action generates a fresh one. They are what the module's $cwa.auth.resendVerifyEmail() and resendVerifyNewEmail() call.
Username not found./resend-verify-email/{username} only works from the release that gave it its own path. It was previously registered at the same path as /verify-email/{username}/{token}, and Symfony resolves a duplicate path to the first match — so the action was unreachable and the path the module calls was registered nowhere, returning 404.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 emails (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
repeat_ttl_seconds: 300 # minimum time between confirmation emails (default)
Cancelling a pending change
Clear newEmailAddress to cancel a pending change: PATCH the user with newEmailAddress: null, or submit the new-email form with an empty value. The confirmation link that was already emailed stops working (it returns 404), and a new change can be requested straight away. The admin user page has a Cancel change button that does this.
Throttled and Failed Email Requests
The password reset request and both resend-verification endpoints are throttled per user. Each flow has its own setting:
| Endpoint | Setting | Default |
|---|---|---|
/password/reset/request/{username} | user.password_reset.repeat_ttl_seconds | 86400 (24 hours) |
/resend-verify-email/{username} | user.email_verification.repeat_ttl_seconds | 300 (5 minutes) |
/resend-verify-new-email/{username} | user.new_email_confirmation.repeat_ttl_seconds | 300 (5 minutes) |
The wait starts when an email is actually sent, including the verification or confirmation email sent after registration or an email change. Each endpoint responds with one of these:
| Status | Meaning |
|---|---|
| 200 | The email was sent |
| 404 | Unknown username |
| 400 | The link was refused (see When a link is refused) |
| 429 | Throttled. Retry-After gives the seconds left. No email is sent and nothing changes |
| 503 | The email couldn't be sent. Nothing is saved, so the previous link still works and the user can retry straight away |
The 200, 429 and 503 responses are marked private and never cached. This is from 2.0.0-alpha.5. In 2.0.0-alpha.4 and earlier, a throttled request returned 200 and sent nothing, and password_reset.repeat_ttl_seconds throttled all three flows.
Retry-After to the CORS expose_headers in config/packages/nelmio_cors.yaml. Without it, browser code can't read the header, and the module's message says "shortly" with no countdown. The template exposes both Link and Retry-After. Projects created before its 2.0.0-alpha.2 release expose only Link, which is enough when the API is served under the site's own /_api path.nelmio_cors:
defaults:
expose_headers: ['Link', 'Retry-After']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.
Independently of route_security, a route whose go-live date has not arrived is hidden in both places from anyone who fails publishable.permission. See Scheduling and Taking Routes Offline.
Public Access Follows Routes
One rule decides what anonymous visitors can read:
A resource is publicly readable if and only if it is reachable from a Route that exists and is live now.
"Reachable" means following the page hierarchy down from that Route: its own Page or PageData, every ancestor above it (through parentPage / parentPageData), the template Page a PageData renders through, and the components placed in any of them. "Live" means its go-live date has passed, including any date inherited from an ancestor. See Scheduling and Taking Routes Offline.
Two consequences are worth knowing:
- Nested pages under an unrouted parent render for the public. A child's live Route makes its whole ancestor chain readable, so a grouping parent with no URL of its own still renders at its depth.
- A page that nothing routes to is private. No Route on the page and none on any page below it means its Page, PageData and components are readable only by users who pass
routable_security.
Routes themselves are always held to this rule. For pages, page data and components it is enforced when routable_security is set, as it is in the template app. Reachability is resolved per request by walking the page hierarchy; there is no join table to rebuild and no command to run.
routable_security sees no change at all.routable_security
Controls who can read pages, page data and components that are not publicly reachable, and who can create new pages and page data:
silverback_api_components:
routable_security: "is_granted('ROLE_ADMIN')"
Without it, every Page and PageData record, and every component on them, is readable by anyone, whether or not a live Route reaches it. The template app sets it.
When routable_security is set:
GET (read access)
- Users who pass the expression see everything, including unrouted, scheduled and offline pages.
- Users who fail it can fetch any page or page data that is publicly reachable, and any template Page that reachable page data renders through. Collections (
GET /_/pages, page-data collections) list only records with their own live Route, so an unrouted parent is readable by IRI but not listed. - Anything else is denied: 401 for an anonymous visitor, 403 for a signed-in user.
This stops anonymous users discovering templates, drafts and scheduled content through the API.
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 secured by the same reachability rule, built into the bundle via ComponentVoter. The only configuration it needs is routable_security: without it, every check below ends in access being granted.
When a GET request is made for a specific component IRI, the bundle checks whether that component is accessible to the current user by:
- Page check — is the component placed (directly, through a layout, or nested inside another component) on a page that is publicly reachable? If yes, access is granted.
- PageData check — is the component referenced as a property on a
PageDataresource that is reachable? If yes, access is granted. The property can be typed as the component's own class or any parent class, such asAbstractComponent. - 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, access falls back to
routable_security: users who pass it can read the component, everyone else is denied (401 when anonymous).
This means components placed exclusively on admin, unrouted or not-yet-live pages are hidden from anonymous API clients.
#[Silverback\Publishable] or your own security expression if a component must stay private before it is placed.2.0.0-alpha.5, a page data property typed as a parent class (ManyToOne(targetEntity: AbstractComponent::class), or a class the component extends) wasn't seen as a location. A component held only that way was readable by anyone. It now follows the page data: public only while the page data is reachable from a live route, and otherwise limited to users who pass routable_security. Editing it also purges the page data from the cache and publishes it to Mercure, which it didn't before.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. Without it, a username or email address that is already taken fails validation: the command prints each violation and exits with an error, and nothing is saved |
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