Skip to content

Fix: Directus Not Working — API Returning 403, Items Not Appearing, or Flows Not Triggering

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix Directus issues — permissions, access policies, collections, REST and GraphQL APIs, file uploads, Flows automation, and self-hosted deployment.

The Problem

A Directus API request returns 403 even though you’re authenticated:

GET /items/articles
→ 403 Forbidden: {"errors":[{"message":"You don't have permission to access this.","extensions":{"code":"FORBIDDEN"}}]}

Or items you created in the Data Studio don’t appear through the API:

{ "data": [] }

Or a Flow (automation) never triggers despite the event firing:

POST /items/orders → 201 Created
// onCreateOrder Flow: never triggered

Why This Happens

Directus v10/v11 uses a role-based access control (RBAC) system built around policies and roles. Unlike older CMS platforms, Directus denies access to everything by default. This is a deliberate departure from Strapi v3 and WordPress, both of which expose new content publicly until you explicitly restrict it. The Directus model is closer to AWS IAM — every operation requires an explicit allow, including reads.

  • Every role needs an explicit policy — creating a collection and adding items does not automatically expose them through the API. You must assign a role with a policy that grants access. Policies are new in v11; pre-v11 codebases used permissions directly on roles, and migration scripts don’t always carry every rule across cleanly.
  • Public role has no permissions — unauthenticated requests use the “Public” role, which starts with zero permissions. You must explicitly grant read access for public endpoints. This is the most common cause of “I created a collection, added an item, and got 403.”
  • Access tokens are scoped — static API tokens inherit the permissions of the user they’re associated with. A token from a restricted user can’t access more than that user can. Confusingly, a token from the admin user is “all-powerful” and skips RBAC entirely, which makes admin-token testing misleading — your code works in dev with the admin token and breaks in staging with a real role.

A second class of issue: Directus exposes both REST and GraphQL endpoints from the same data model, but the permissions are evaluated identically. A 403 in REST means a 403 in GraphQL. The error formatting differs (REST returns an errors[] array, GraphQL embeds it in the errors field at the response root), so you may not realize you’re hitting the same policy. When debugging, always test the equivalent REST endpoint first — it’s faster and the error messages are more direct.

Fix 1: Permissions and Access Policies

In the Directus Data Studio, go to Settings → Access Control.

In Directus v11+, permissions are managed through Policies applied to roles:

  1. Create or select a Role (e.g., “Editor”, “Public”)
  2. Assign a Policy to the role that grants CRUD permissions on specific collections

For public API access (unauthenticated):

Settings → Access Control → Public Role → Add Policy
→ Enable: Read on "articles" collection
→ Filter: { "status": { "_eq": "published" } }  ← only published items

For authenticated users:

Settings → Access Control → Create Role → "API User"
→ Add Policy → Full access to: articles, categories, tags
→ Create a static token for this role

Via the API (admin token required):

// Grant read access to the Public role on the "articles" collection
const adminClient = createDirectus('http://localhost:8055')
  .with(staticToken('your-admin-token'))
  .with(rest());

// Get the Public role ID
const roles = await adminClient.request(readRoles({ filter: { name: { _eq: 'Public' } } }));
const publicRoleId = roles[0].id;

// Create a policy granting read access
await adminClient.request(createPermission({
  role: publicRoleId,
  collection: 'articles',
  action: 'read',
  fields: ['*'],
  filter: { status: { _eq: 'published' } },
}));

Fix 2: Authentication and Tokens

// npm install @directus/sdk
import { createDirectus, rest, authentication, staticToken, readItems } from '@directus/sdk';

// Option 1: Static API token (recommended for server-side)
const client = createDirectus('http://localhost:8055')
  .with(staticToken(process.env.DIRECTUS_TOKEN!))
  .with(rest());

// Option 2: Email/password login
const client = createDirectus('http://localhost:8055')
  .with(authentication('json'))
  .with(rest());

await client.login('[email protected]', 'password');

// Access token for API calls
const token = await client.getToken();
console.log(token); // eyJ...

// Option 3: Direct fetch with token
const res = await fetch('http://localhost:8055/items/articles', {
  headers: {
    Authorization: `Bearer ${process.env.DIRECTUS_TOKEN}`,
    'Content-Type': 'application/json',
  },
});
// Refresh tokens — the SDK handles this automatically with authentication()
// But if using static tokens, they don't expire

// Get a temporary token for browser use
const loginRes = await fetch('http://localhost:8055/auth/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: '[email protected]', password: 'password' }),
});
const { data: { access_token, refresh_token } } = await loginRes.json();

// Refresh
const refreshRes = await fetch('http://localhost:8055/auth/refresh', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ refresh_token }),
});

Fix 3: REST API with the SDK

import { createDirectus, rest, staticToken, readItems, readItem,
         createItem, updateItem, deleteItem } from '@directus/sdk';

interface Article {
  id: number;
  title: string;
  content: string;
  status: 'draft' | 'published' | 'archived';
  author: string | Author; // relation
  date_published: string;
}

interface Author {
  id: string;
  first_name: string;
  last_name: string;
}

interface DirectusSchema {
  articles: Article[];
  authors: Author[];
}

const client = createDirectus<DirectusSchema>('http://localhost:8055')
  .with(staticToken(process.env.DIRECTUS_TOKEN!))
  .with(rest());

// --- Read items ---
const articles = await client.request(
  readItems('articles', {
    filter: { status: { _eq: 'published' } },
    sort: ['-date_published'],
    limit: 10,
    offset: 0,
    fields: ['id', 'title', 'status', 'date_published', { author: ['first_name', 'last_name'] }],
  })
);

// Read a single item
const article = await client.request(
  readItem('articles', 123, {
    fields: ['*', { author: ['*'] }],
  })
);

// --- Write operations ---
const newArticle = await client.request(
  createItem('articles', {
    title: 'New Article',
    content: 'Content here...',
    status: 'draft',
    author: 'user-uuid',
  })
);

await client.request(
  updateItem('articles', 123, { status: 'published' })
);

await client.request(deleteItem('articles', 123));
// Filter operators
const results = await client.request(
  readItems('articles', {
    filter: {
      _and: [
        { status: { _eq: 'published' } },
        { date_published: { _gte: '$NOW(-30 days)' } },
        { title: { _contains: 'TypeScript' } },
      ],
    },
    // Relation filtering
    // filter: { author: { last_name: { _eq: 'Smith' } } },
  })
);

Fix 4: GraphQL API

# GraphQL endpoint (requires graphql permission on the role)
GET/POST http://localhost:8055/graphql
query {
  articles(
    filter: { status: { _eq: "published" } }
    sort: ["-date_published"]
    limit: 10
  ) {
    id
    title
    content
    date_published
    author {
      first_name
      last_name
    }
  }
}

mutation CreateArticle {
  create_articles_item(
    data: { title: "New Article", status: "draft", content: "..." }
  ) {
    id
    title
  }
}
// Using the SDK with GraphQL
import { createDirectus, graphql, staticToken } from '@directus/sdk';

const client = createDirectus('http://localhost:8055')
  .with(staticToken(process.env.DIRECTUS_TOKEN!))
  .with(graphql());

const result = await client.query<{ articles: Article[] }>(`
  query {
    articles(filter: { status: { _eq: "published" } }) {
      id
      title
      date_published
    }
  }
`);

Fix 5: File Uploads

import { createDirectus, rest, staticToken, uploadFiles, readAssetRaw } from '@directus/sdk';

const client = createDirectus('http://localhost:8055')
  .with(staticToken(process.env.DIRECTUS_TOKEN!))
  .with(rest());

// Upload a file
const formData = new FormData();
formData.append('title', 'My Image');
formData.append('folder', 'folder-uuid'); // Optional: target folder
formData.append('file', fileBlob, 'image.jpg');

const uploadedFile = await client.request(uploadFiles(formData));
console.log(uploadedFile.id);   // UUID
console.log(uploadedFile.filename_download); // image.jpg

// Get file URL
const fileUrl = `http://localhost:8055/assets/${uploadedFile.id}`;

// With transformations (images)
const thumbUrl = `http://localhost:8055/assets/${uploadedFile.id}?width=400&height=300&fit=cover&format=webp`;

// Protect files — if the file's folder requires authentication:
const imageRes = await fetch(`http://localhost:8055/assets/${fileId}`, {
  headers: { Authorization: `Bearer ${token}` },
});
// Associate a file with a collection item
await client.request(
  updateItem('articles', articleId, {
    featured_image: uploadedFile.id, // Set the file relation
  })
);

Fix 6: Flows (Automation)

Directus Flows are event-driven automations built in the Data Studio.

Flow not triggering — checklist:

  1. Go to Settings → Flows and verify the Flow is active (toggle is on)

  2. Check the trigger type matches the event:

    • Event Hook → fires on collection CRUD operations
    • Schedule → cron-based (e.g., 0 9 * * 1 = 9am every Monday)
    • Webhook / Manual → triggered by HTTP POST to /flows/trigger/UUID
    • Custom Endpoint → creates a new REST endpoint
  3. Verify the collection and action in the trigger configuration:

Trigger: Event Hook
Type: Filter (Before) or Action (After)
Scope: items.create, items.update, items.delete
Collections: articles  ← must match the collection name exactly
  1. Check the Operations chain — if an early operation throws an error, later operations won’t run. Use the Flow execution log (Settings → Flows → [your flow] → Logs) to see what happened.
// Trigger a Webhook Flow manually
const flowUuid = 'abc123-...';
const res = await fetch(`http://localhost:8055/flows/trigger/${flowUuid}`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${token}`,
  },
  body: JSON.stringify({ articleId: 123 }),
});
// The flow receives body data in the $trigger variable

Fix 7: Self-Hosted Deployment

# docker-compose.yml
services:
  directus:
    image: directus/directus:latest
    ports:
      - "8055:8055"
    volumes:
      - ./uploads:/directus/uploads
      - ./extensions:/directus/extensions
    environment:
      SECRET: "your-random-secret-string"
      ADMIN_EMAIL: "[email protected]"
      ADMIN_PASSWORD: "your-secure-password"

      DB_CLIENT: "pg"
      DB_HOST: "postgres"
      DB_PORT: "5432"
      DB_DATABASE: "directus"
      DB_USER: "directus"
      DB_PASSWORD: "directus"

      PUBLIC_URL: "https://api.your-domain.com"
      CORS_ENABLED: "true"
      CORS_ORIGIN: "https://your-frontend.com,http://localhost:3000"

      # Email (optional)
      EMAIL_FROM: "[email protected]"
      EMAIL_TRANSPORT: "smtp"
      EMAIL_SMTP_HOST: "smtp.example.com"
      EMAIL_SMTP_PORT: "587"
      EMAIL_SMTP_USER: "smtp-user"
      EMAIL_SMTP_PASSWORD: "smtp-password"

  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: directus
      POSTGRES_PASSWORD: directus
      POSTGRES_DB: directus
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
docker compose up -d

# Check logs
docker compose logs directus -f

# Backup
docker exec -t directus-postgres-1 pg_dump -U directus directus > backup.sql

Fix 8: Directus vs Strapi vs Payload vs Hasura vs PostgREST

Directus competes in a crowded space, and the choice between these tools changes the kinds of errors you’ll hit later.

Directus is database-first. You point it at an existing Postgres, MySQL, or SQLite database and it generates the admin UI and APIs from the schema. The killer feature is that any change in the database — a new column added by a migration — appears in the admin UI immediately. The cost is that Directus owns the schema dialect: certain features (relations, file fields) require specific column types and metadata stored in directus_* system tables. Bringing in an existing legacy schema works but requires careful mapping.

Strapi is content-first. You define content types in the admin UI, and Strapi generates the database schema. The flow is the reverse of Directus. Strapi v5 ships with a much-improved TypeScript story and document-versioning support. Pick Strapi when you’re building from scratch and want a familiar headless-CMS workflow.

Payload is code-first. Your content schema is a TypeScript config file, version-controlled alongside your app. The admin UI is generated from that config. Payload runs as a Next.js plugin (Payload 3+), so you deploy it the way you deploy any Next.js app. Pick Payload when you want type-safe content models, no external CMS process, and TypeScript-first ergonomics.

Hasura is not a CMS — it’s a GraphQL engine over Postgres. It exposes every table as a GraphQL type with subscriptions, but there’s no admin UI for content editors. Pick Hasura when you want auto-generated GraphQL with real-time subscriptions and don’t need non-developers to edit data. The permissions model (row-level rules in JSON) is more expressive than Directus’s policies but more verbose.

PostgREST is the minimalist option. It exposes a Postgres database as a REST API and that’s it — no admin UI, no auth UI, no file storage. It powers Supabase’s REST layer internally. Pick PostgREST when you want maximum control over the database and minimal vendor surface, and you’re happy to build your own admin tooling.

Rule of thumb: Directus and Strapi are the closest competitors — pick Directus for existing databases, Strapi for greenfield projects with non-technical editors. Payload wins when your team is TypeScript-fluent and wants the CMS in the codebase. Hasura wins when GraphQL subscriptions are the headline requirement. PostgREST wins when you want a thin API layer and nothing more.

Still Not Working?

403 on all requests — the token’s user/role has no policy granting access. Go to Settings → Access Control → find the role → add a policy. For public access, edit the Public role directly.

Empty data array — check two things: (1) the collection has items in the Data Studio, and (2) the filter in the policy doesn’t exclude them (e.g., a filter on status = "published" hides draft items).

Flow never fires — verify the Flow is active, the trigger type matches, and the collection name is exact (case-sensitive). Open the Flow’s execution log to see if it ran and where it failed.

CORS error in browser — add your frontend origin to CORS_ORIGIN in your environment config. Multiple origins are comma-separated. Restart Directus after changing env variables.

DATABASE_URL not working — Directus prefers individual DB_* env vars over a single connection string. Use DB_CLIENT, DB_HOST, DB_PORT, DB_DATABASE, DB_USER, and DB_PASSWORD separately.

SDK requests work but raw fetch returns 401 — the SDK auto-attaches the Authorization: Bearer <token> header. If you switch to raw fetch for a one-off call, you must add the header yourself. The token must also include the Bearer prefix; sending just the JWT string returns 401.

Migrations leave permissions broken after upgrade — upgrading from v10 to v11 introduces policies as a new layer between roles and permissions. Existing permissions get migrated, but custom-installed extensions may register routes that the policy migration doesn’t see. After any major upgrade, audit Settings → Access Control → Public role and confirm read access still works on every public-facing collection.

File upload returns 200 but the file doesn’t appear — Directus accepts the multipart upload, but the storage adapter (local disk, S3, etc.) may have failed silently. Check docker logs directus for errors mentioning the storage driver. On Docker, the ./uploads volume mount must be writable by the container’s user (UID 1000 by default).

For related CMS and backend issues, see Fix: Strapi Not Working, Fix: PocketBase Not Working, Fix: Payload CMS Not Working, and Fix: TanStack Query Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles