---
title: "Security"
manual: "TCA_API"
version: "main"
permalink: "https://docs.typo3.org/permalink/maikschneider/tca-api:security@main"
source: "Configuration/Security.rst"
rendered: "2026-10-27T16:14:15+00:00"
---

# Security {#security}

The `security` section assigns an access role to each operation. Access is
checked by the `AccessController` before the request reaches the operation
handler.

How a client authenticates as a frontend user, a backend user or with an API
key is described in [Authentication](https://docs.typo3.org/permalink/maikschneider/tca-api:authentication@main).

## Default access roles {#default-access-roles}

Operations not listed in `security` fall back to sensible defaults:

| Operation | Default | Explanation |
| --- | --- | --- |
| `list` | `PUBLIC` | Read operations are publicly accessible without authentication. |
| `show` | `PUBLIC` | Read operations are publicly accessible without authentication. |
| `create` | `DISABLED` | Write operations are **disabled** until explicitly configured. |
| `update` | `DISABLED` | Write operations are **disabled** until explicitly configured. |
| `delete` | `DISABLED` | Write operations are **disabled** until explicitly configured. |

> [!IMPORTANT]
> Listing an operation in `general.operations` is not enough to enable it.
> Write operations (`create`, `update`, `delete`) also require an
> explicit `security` entry. Without one they return **403 Forbidden**.

## Access roles {#access-roles}

| Role | Description |
| --- | --- |
| `AccessRole::PUBLIC` | No authentication required. Anyone can access the endpoint. |
| `AccessRole::DISABLED` | Always denied regardless of authentication. Used as the implicit default for write operations that have no explicit security configuration. |
| `AccessRole::FE_USER` | Requires a logged-in frontend user. |
| `AccessRole::FE_GROUP` | Requires a frontend user belonging to a specific group. Use `[AccessRole::FE_GROUP, [1,2]]` to restrict to specific group IDs. |
| `AccessRole::BE_USER` | Requires any authenticated backend user. |
| `AccessRole::BE_ADMIN` | Requires an admin backend user. |
| `AccessRole::OWNER` | Authenticated FE user whose UID matches the record's ownership column. See [Ownership](https://docs.typo3.org/permalink/maikschneider/tca-api:ownership@main) below. |
| `AccessRole::API_KEY` | Requires a credential sent with the request (API key, token) instead of a session. Use `[AccessRole::API_KEY, ['articles:write']]` to also require scopes. See [Scopes](https://docs.typo3.org/permalink/maikschneider/tca-api:scopes@main) below. |

## Configuration {#configuration}

```php
use MaikSchneider\TcaApi\Enum\AccessRole;

'security' => [
    // list and show are PUBLIC by default — only specify to restrict them
    'create' => AccessRole::FE_USER,
    'update' => AccessRole::OWNER,      // Only the record owner may update
    'delete' => AccessRole::OWNER,
],
```

## Callable voters {#callable-voters}

For custom access logic, use a callable instead of an `AccessRole` enum value.
The callable receives the PSR-7 server request and an optional record array (for
object-level security):

```php
'security' => [
    'create' => [MyAccessChecker::class, 'checkCreatePermission'],
],
```

The callable must return `true` to grant access or `false` to deny it. The
caller is available as `Principal::fromRequest($request)`, see
[Authenticators](https://docs.typo3.org/permalink/maikschneider/tca-api:authenticators@main).

```php
class MyAccessChecker
{
    public static function checkCreatePermission(
        \Psr\Http\Message\ServerRequestInterface $request,
        ?array $record = null
    ): bool {
        // Custom access logic
        return true;
    }
}
```

## Scopes {#scopes}

Credentials can carry **scopes** that limit what they may do. A scope names a
resource and an access level:

| Scope | Grants |
| --- | --- |
| `articles:read` | `list`, `show` and `userinfo` on `articles` |
| `articles:write` | `create`, `update` and `delete` on `articles` |
| `articles:*` | All operations on `articles` |
| `*:read` | Read access to every resource |

**Scopes always restrict.** A credential with scopes may only perform the
operations they cover, even when its role would allow more, so a key bound to a
frontend user is never more powerful than its scopes. This also applies to
`PUBLIC` operations and to nested creates into another resource. Sessions and
credentials without scopes are unrestricted.

A role can additionally require scopes:

```php
'security' => [
    'show'   => AccessRole::API_KEY,                       // any credential
    'create' => [AccessRole::API_KEY, ['articles:write']], // a credential with the scope
    'update' => AccessRole::FE_USER,                       // session or user-bound credential
],
```

Invalid scope strings fail when the configuration is loaded. Which scopes a
credential carries is decided by the extension that authenticates it, see
[Authenticators](https://docs.typo3.org/permalink/maikschneider/tca-api:authenticators@main).

## Ownership {#ownership}

The `ownership` section enables declarative record-level security for write
operations. It pairs with `AccessRole::OWNER` in the `security` config.

```php
use MaikSchneider\TcaApi\Enum\AccessRole;

'security' => [
    'create' => AccessRole::FE_USER,
    'update' => AccessRole::OWNER,
    'delete' => AccessRole::OWNER,
],
'ownership' => [
    'column' => 'fe_user_id',   // DB column holding the owner's FE user UID
],
```

**What this does:**

-   **On create** — the `fe_user_id` column is automatically set to the
    authenticated FE user's UID server-side. The client cannot supply this value;
    it is stripped from the request body regardless of the column's `groups`
    config.
-   **On update/delete** — the record's `fe_user_id` is compared to the current
    FE user's UID. If they don't match the request returns **403**.

### Separate tracking vs. auth columns {#separate-tracking-vs-auth-columns}

Use `setOnCreate` when you want an additional tracking column alongside the
auth column:

```php
'ownership' => [
    'column'      => 'fe_user_id',      // column checked on update/delete (also written on create)
    'setOnCreate' => 'fe_creator_id',   // additional column written on create only
],
```

On create, **both** `column` and `setOnCreate` receive the FE user UID.
`column` must be populated for `OWNER` auth to work on subsequent
update/delete; `setOnCreate` provides an immutable "created by" audit trail in
a separate DB column.

### BE_ADMIN bypass {#be-admin-bypass}

Backend admins bypass ownership checks by default. Set `beAdminBypass: false`
to enforce ownership for admins too:

```php
'ownership' => [
    'column'        => 'fe_user_id',
    'beAdminBypass' => false,           // default: true
],
```

### Ownership config reference {#ownership-config-reference}

| Key | Required | Default | Description |
| --- | --- | --- | --- |
| `column` | When using `OWNER` | — | DB column holding owner UID; compared on update/delete. |
| `setOnCreate` | No | Same as `column` | Column auto-set on create (if different from `column`). |
| `beAdminBypass` | No | `true` | When `true`, `BE_ADMIN` skips the ownership check. |

### Behaviour notes {#behaviour-notes}

-   `AccessRole::OWNER` without `ownership.column` configured → **403**
    (fail-secure).
-   Unauthenticated request + `OWNER` → **403** (no FE user found).
-   `setOnCreate` without a logged-in FE user → column is not set (no injection
    if user is null).
-   Ownership columns are always stripped from client input regardless of
    `groups` config.
-   `OWNER` is only meaningful on `update` and `delete`; using it on
    `list`/`show` will always deny (no single record to compare against).

## Table write restrictions {#table-write-restrictions}

The API enforces a built-in **deny list** for tables that must never be written
through the API regardless of security configuration. Attempting to write to
any of these tables returns **403 Forbidden**:

-   `be_users`, `be_groups`, `be_sessions`
-   `fe_sessions`
-   `sys_filemounts`, `sys_be_shortcuts`, `sys_action`, `sys_log`

This protection is unconditional — no resource configuration can unblock these
tables. The policy can be tightened further (an allow list, or additional denied
tables) and every write is audit-logged; see [Write privilege model](https://docs.typo3.org/permalink/maikschneider/tca-api:write-privileges@main).

## Denied access {#denied-access}

When access is denied, the API returns:

-   **401 Unauthorized** — when the request carries credentials that are
    invalid, e.g. a wrong, expired or revoked API key.
-   **403 Forbidden** — when the caller is anonymous or does not have
    sufficient privileges.

See [How tca_api sees identity](https://docs.typo3.org/permalink/maikschneider/tca-api:authentication-identity@main).
