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
  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 (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

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:outputPending 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_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

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] }
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.

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.

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.

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 route assigned — 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:

  1. Route check — is the component part of a page that has a publicly accessible route? If yes, access is granted.
  2. PageData check — is the component referenced as a property on a PageData resource with a reachable route? If yes, access is granted.
  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 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.

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. Use #[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.

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 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