The CWA is in heavy development
The CWA is still in alpha and not ready for production - some code and implementations are likely to change. If you would like to try out the CWA, please enjoy what we have provided and feel free to provide feedback, or get involved on GitHub.
DraftApi

Users & Security

JWT cookie authentication, the AbstractUser entity, email verification, password reset, and Symfony security configuration.

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

  1. Client sends credentials to POST /login (/_api/login in the template, where the bundle's routes are mounted under /_api)
  2. API validates, issues a JWT and a refresh token — both as HttpOnly cookies
  3. Every subsequent request sends the cookies automatically (same-origin or configured CORS)
  4. When the JWT expires, the API auto-refreshes it using the refresh token cookie
  5. 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

FieldSerialization groupNotes
usernameUser:outputUsed as login identifier
emailAddressUser:outputUnique; separate from username
rolesUser:output, User:superAdminArray. Expanded through role_hierarchy on output, so ROLE_USER is always present even though only the assigned role is stored
enabledUser:superAdminDisabled users cannot log in
plainPasswordUser:input (write-only)Hashed before persist
emailAddressVerifiedUser:outputSet by email verification flow
newEmailAddressUser:input, User:outputPending 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_components
The cookie name has to match in three places: lexik_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] }
The ^/_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.

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 Origin header (or Referer when there's no Origin) is used only when it matches one of allowed_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.

Upgrading to 2.0.0-alpha.4 or later: set 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.
The bundle anchors each 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.

What happens depends on whether the email is the request's whole job:

EmailWhen the link is refused
Password reset request, and both resend-verification endpoints400, 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 changeThe 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:

RoutePurpose
/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.

An unknown username returns 404 on both resend routes, matching the password-request endpoint. Earlier releases returned a 500 leaking 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:

EndpointSettingDefault
/password/reset/request/{username}user.password_reset.repeat_ttl_seconds86400 (24 hours)
/resend-verify-email/{username}user.email_verification.repeat_ttl_seconds300 (5 minutes)
/resend-verify-new-email/{username}user.new_email_confirmation.repeat_ttl_seconds300 (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:

StatusMeaning
200The email was sent
404Unknown username
400The link was refused (see When a link is refused)
429Throttled. Retry-After gives the seconds left. No email is sent and nothing changes
503The 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.

If your front end is on a different origin from the API, add 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.

The two enforcement mechanisms translate * 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 Route collection 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 Route by 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.

Upgrading: components on a page that nothing routes to used to be publicly readable even though the page was not. They are now private too. If you deliberately served components from an unrouted page, give that page a Route or place it under a routed one. Components that are not placed on any page are unchanged, and an application that does not set 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:

  1. 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.
  2. PageData check — is the component referenced as a property on a PageData resource that is reachable? If yes, access is granted. The property can be typed as the component's own class or any parent class, such as AbstractComponent.
  3. Template check — is the component placed in a page template used by reachable page data? If yes, access is granted.
  4. 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.

A component with no location at all — not in any component group, not referenced by page data, not in a template — makes all three checks abstain, and the voter then grants access. An unplaced or orphaned component is readable by anyone who knows its IRI (the orphaned resource report lists them). Use #[Silverback\Publishable] or your own security expression if a component must stay private before it is placed.
Upgrading: up to 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.

FlagEffect
(none)Roles set to ['ROLE_USER']
--adminRoles set to ['ROLE_ADMIN']
--super-adminRoles set to ['ROLE_SUPER_ADMIN']
--inactiveCreates the account disabled — it cannot log in until enabled is set
--overwriteUpdates 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