Fix: PocketBase Not Working — Auth Failing, Real-time Subscriptions Broken, or Collection Rules Blocking Requests
Part of: Database Errors
Quick Answer
How to fix PocketBase issues — authentication, collection access rules, real-time subscriptions, file uploads, relations, and self-hosted deployment.
The Problem
PocketBase auth returns a 400 error:
POST /api/collections/users/auth-with-password
→ 400 Bad Request: {"code":400,"message":"Failed to authenticate.","data":{}}Or a collection fetch fails with 403 even though you’re logged in:
GET /api/collections/posts/records
→ 403 Forbidden: {"code":403,"message":"Only admins can perform this action.","data":{}}Or a real-time subscription never fires:
pb.collection('posts').subscribe('*', (e) => {
console.log(e.record); // Never called
});Why This Happens
PocketBase is a self-contained Go backend with its own SQLite database, auth system, and real-time engine. It ships as a single binary, which is the headline difference between PocketBase and every hosted alternative — there is no managed control plane sitting between your code and the data. That simplicity changes how errors surface. Where Firebase or Supabase fail with a billing-plan message or a dashboard quota warning, PocketBase fails with a flat HTTP status because the process either ran the rule successfully or it did not. Most issues come from three sources:
- Collection access rules are empty by default — a new collection blocks all access until you explicitly set rules. An empty rule field means “deny everyone,” including authenticated users. This is the inverse of Firebase Realtime Database’s legacy default-open behavior and trips up developers migrating across stacks.
- Auth tokens expire or aren’t attached — PocketBase tokens last 7 days by default. If you don’t attach the token to requests, every fetch is treated as anonymous. The SDK persists the token in
localStoragefor browsers, but server-side calls must pass the token explicitly. - Real-time requires a persistent connection — SSE subscriptions break behind proxies that buffer responses or time out idle connections. Nginx in particular needs specific configuration. Cloudflare’s free tier will close idle SSE connections after 100 seconds, so the subscription “dies” silently from the client’s point of view.
A second class of failures comes from misunderstanding the SQLite-on-disk model. Because the entire database lives in pb_data/data.db, the file is locked while PocketBase writes. Running a second PocketBase process against the same pb_data directory, or backing up the file with a plain cp during heavy writes, can corrupt the database. Use the built-in backup API or stop the process before copying.
Fix 1: Authentication
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// Authenticate as a regular user
const authData = await pb.collection('users').authWithPassword(
'[email protected]',
'securepassword'
);
// authData.token contains the JWT
console.log(pb.authStore.isValid); // true
console.log(pb.authStore.token); // eyJ...
// The client automatically attaches the token to subsequent requests
const records = await pb.collection('posts').getList(1, 20);// Persist auth across page reloads (browser)
const pb = new PocketBase('http://127.0.0.1:8090');
// PocketBase automatically saves auth to localStorage in the browser.
// On page reload, authStore is restored from localStorage.
if (pb.authStore.isValid) {
// Already logged in — optionally refresh the token
try {
await pb.collection('users').authRefresh();
} catch {
pb.authStore.clear(); // Token expired — log out
}
}
// Listen for auth state changes
pb.authStore.onChange((token, model) => {
console.log('Auth changed:', !!token, model?.email);
});// OAuth2 authentication
const authData = await pb.collection('users').authWithOAuth2({
provider: 'google',
// PocketBase opens a popup and handles the OAuth flow
});
// Admin authentication (separate from user auth)
await pb.admins.authWithPassword('[email protected]', 'adminpassword');
// Now pb.authStore.isAdmin === trueNote: User auth and admin auth are stored in the same pb.authStore. Authenticating as admin overwrites user auth and vice versa. If you need both simultaneously, create two PocketBase instances.
Fix 2: Collection Access Rules
Access rules control who can read or write each collection. An empty rule means no one can access it — not even authenticated users.
Open the PocketBase Admin UI (http://127.0.0.1:8090/_/) → Collections → your collection → API Rules:
# Allow anyone to list and view records
List rule: (leave empty → everyone) OR "" (empty string = allow all)
View rule: ""
# Allow only the record's owner to create/update/delete
Create rule: @request.auth.id != ""
Update rule: @request.auth.id = author.id
Delete rule: @request.auth.id = author.idCommon rule patterns:
# Public read, auth required for write
List/View: (empty or "")
Create: @request.auth.id != ""
Update/Delete: @request.auth.id = user.id
# Authenticated users only (any logged-in user)
List/View: @request.auth.id != ""
# Owner-only access
List: @request.auth.id = user.id
View: @request.auth.id = user.id
Update: @request.auth.id = user.id
Delete: @request.auth.id = user.id
# Admin-only (no rule = block all; use admin API instead)
# Leave all rules empty and use pb.admins.authWithPassword()You can also set rules via the API or migrations:
// Via PocketBase migrations (pb_migrations/)
migrate((db) => {
const collection = db.findCollectionByNameOrId('posts');
collection.listRule = ''; // Public
collection.viewRule = '';
collection.createRule = '@request.auth.id != ""';
collection.updateRule = '@request.auth.id = author.id';
collection.deleteRule = '@request.auth.id = author.id';
db.saveCollection(collection);
});Fix 3: CRUD Operations
const pb = new PocketBase('http://127.0.0.1:8090');
// --- List with filtering, sorting, pagination ---
const result = await pb.collection('posts').getList(1, 20, {
filter: 'published = true && created >= "2024-01-01"',
sort: '-created', // - prefix = descending
expand: 'author,category', // expand relation fields
fields: 'id,title,created,expand.author.name', // select specific fields
});
// result.items, result.totalItems, result.totalPages
// Get all records (auto-paginates internally)
const allPosts = await pb.collection('posts').getFullList({
sort: '-created',
filter: 'published = true',
});
// Get a single record
const post = await pb.collection('posts').getOne('RECORD_ID', {
expand: 'author',
});
// Get first match
const post = await pb.collection('posts').getFirstListItem('slug = "my-post"');
// --- Create ---
const newPost = await pb.collection('posts').create({
title: 'Hello World',
content: 'My first post',
published: false,
author: pb.authStore.model?.id, // relation field — store the record ID
});
// --- Update ---
const updatedPost = await pb.collection('posts').update('RECORD_ID', {
title: 'Updated Title',
published: true,
});
// --- Delete ---
await pb.collection('posts').delete('RECORD_ID');// Filter syntax reference
const results = await pb.collection('orders').getList(1, 50, {
filter: `
status = "active" &&
total > 100 &&
user.email ~ "@company.com" &&
created >= "${new Date(Date.now() - 7 * 86400000).toISOString()}"
`,
});
// Operators: =, !=, >, <, >=, <=, ~ (like), !~ (not like)
// Logical: &&, ||
// Relation traversal: user.email, category.nameFix 4: File Uploads
// Upload a file with a record
const formData = new FormData();
formData.append('title', 'My Photo');
formData.append('image', fileInput.files[0]); // File object
const record = await pb.collection('photos').create(formData);
// Get the file URL
const imageUrl = pb.getFileUrl(record, record.image);
// Returns: http://127.0.0.1:8090/api/files/photos/RECORD_ID/filename.jpg
// With a thumbnail (for images)
const thumbUrl = pb.getFileUrl(record, record.image, { thumb: '100x100' });
// Multiple files (if field allows multiple)
formData.append('attachments', file1);
formData.append('attachments', file2);// Update/replace a file
const updateData = new FormData();
updateData.append('avatar', newAvatarFile);
const updated = await pb.collection('users').update(userId, updateData);
// Remove a specific file (multi-file fields)
await pb.collection('posts').update(postId, {
'attachments-': ['filename.pdf'], // prefix with - to remove
});Pro Tip: PocketBase stores files in pb_data/storage/. In production, mount this directory as a persistent volume or configure an S3-compatible storage backend via the Admin UI → Settings → File storage.
Fix 5: Real-time Subscriptions
// Subscribe to all changes in a collection
const unsubscribe = await pb.collection('posts').subscribe('*', (e) => {
console.log(e.action); // 'create' | 'update' | 'delete'
console.log(e.record); // The affected record
});
// Subscribe to a specific record
await pb.collection('posts').subscribe('RECORD_ID', (e) => {
console.log('Post changed:', e.record.title);
});
// Unsubscribe when done (e.g., component unmount)
unsubscribe();
// Or unsubscribe all at once:
pb.collection('posts').unsubscribe();
// React example — proper cleanup
useEffect(() => {
let unsubscribe: (() => void) | undefined;
pb.collection('messages').subscribe('*', (e) => {
setMessages((prev) =>
e.action === 'create' ? [...prev, e.record] :
e.action === 'delete' ? prev.filter((m) => m.id !== e.record.id) :
prev.map((m) => m.id === e.record.id ? e.record : m)
);
}).then((fn) => { unsubscribe = fn; });
return () => { unsubscribe?.(); };
}, []);If subscriptions never fire, check the proxy configuration. PocketBase uses Server-Sent Events (SSE), which require the proxy to pass the connection through without buffering:
# nginx — required for SSE/real-time to work
location /api/realtime {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Connection ''; # Keep-alive, no upgrade
proxy_buffering off; # Critical — SSE breaks with buffering
proxy_cache off;
proxy_read_timeout 3600s; # Allow long-lived connections
chunked_transfer_encoding on;
}
location / {
proxy_pass http://127.0.0.1:8090;
proxy_set_header Host $host;
}Fix 6: Relations and Expand
// Define a relation in the collection schema:
// Field: author → type: Relation → Collection: users → Single
// Fetch with expanded relation
const post = await pb.collection('posts').getOne('RECORD_ID', {
expand: 'author',
});
// post.author === 'user_id_string'
// post.expand?.author === { id, name, email, ... }
// Multiple relations
const post = await pb.collection('posts').getOne('RECORD_ID', {
expand: 'author,tags,category.parent',
});
// Reverse relation (back-reference)
// posts collection has field: author → users
// To get all posts by a user, filter instead of expanding:
const userPosts = await pb.collection('posts').getList(1, 50, {
filter: `author = "${userId}"`,
expand: 'author',
});
// For reverse expand (user → posts), define a back-relation field in users
// or use the filter approach aboveFix 7: Self-Hosted Deployment
# Download PocketBase binary
wget https://github.com/pocketbase/pocketbase/releases/latest/download/pocketbase_linux_amd64.zip
unzip pocketbase_linux_amd64.zip
# Run (data stored in pb_data/ by default)
./pocketbase serve --http="0.0.0.0:8090"
# Production: run as a systemd service
sudo nano /etc/systemd/system/pocketbase.service[Unit]
Description=PocketBase
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/pocketbase
ExecStart=/opt/pocketbase/pocketbase serve --http="0.0.0.0:8090"
Restart=on-failure
RestartSec=5s
StandardOutput=append:/var/log/pocketbase/output.log
StandardError=append:/var/log/pocketbase/error.log
[Install]
WantedBy=multi-user.targetsudo systemctl enable pocketbase
sudo systemctl start pocketbase# Backup the database
cp pb_data/data.db pb_data/data.db.backup
# Or use the built-in backup API (Admin only)
curl -H "Authorization: Admin eyJ..." \
http://127.0.0.1:8090/api/backups \
-X POST -H "Content-Type: application/json" \
-d '{"name": "backup.zip"}'Fix 8: PocketBase vs Supabase vs Firebase vs Appwrite
If you’re choosing a backend, the surface APIs look similar but the underlying models differ in ways that affect what breaks and how you fix it.
PocketBase is a single Go binary with embedded SQLite, an admin UI, file storage, and SSE-based realtime. You self-host, you control the file, and there is no per-request pricing. The trade-off: writes are bound to a single node’s disk, so horizontal scaling is not a feature — you scale vertically or shard at the application layer. Realtime uses SSE rather than WebSockets, which simplifies proxy setup at small scale but punishes you on proxies that buffer (Cloudflare free tier, Nginx without proxy_buffering off).
Supabase is Postgres plus PostgREST plus a separate realtime server that tails the WAL. Row-Level Security (RLS) replaces PocketBase’s collection rules. Because Supabase is real Postgres, you get joins, window functions, extensions, and proper horizontal read replicas. The cost is operational complexity — when an RLS policy blocks a query, the error surfaces with a Postgres error code rather than a clean 403, and you debug it with EXPLAIN rather than a rule string.
Firebase uses Cloud Firestore (document store) with security rules written in a custom DSL. The Firestore client streams snapshots over a long-poll connection that handles proxies more gracefully than SSE. The hard limits: queries cannot do real joins, composite indexes must be declared in advance, and vendor lock-in is total. Migrating off Firestore later requires rewriting data access entirely.
Appwrite is conceptually the closest to PocketBase — self-hostable, document-style collections, attribute-level rules — but is implemented as a multi-container Docker stack with MariaDB, Redis, and a worker pool. Realtime uses WebSockets rather than SSE. If you outgrow PocketBase’s single-binary model but want to stay self-hosted, Appwrite is the natural upgrade path.
Rule of thumb: pick PocketBase for prototypes, internal tools, and small SaaS where you want zero managed infrastructure. Pick Supabase when you need real SQL or scale beyond one node. Pick Firebase when realtime mobile sync is the core feature. Pick Appwrite when you want PocketBase’s developer experience but need the horizontal scale of separate database, cache, and worker containers.
Still Not Working?
403 on every request — check the collection’s API Rules in the Admin UI. An empty rule denies all access. Set at minimum "" (empty string) for public access or @request.auth.id != "" for authenticated-only access.
“Failed to authenticate” — verify the user exists, the password is correct, and the email is verified (if you enabled email verification). Check under Collections → users → records in the Admin UI.
Subscriptions disconnect immediately — a proxy is stripping the SSE connection. Add proxy_buffering off and proxy_read_timeout 3600s to your nginx config. Also confirm the connection goes to /api/realtime, not the root.
File uploads return 400 — the collection field must be set to type “File” and you must send the request as multipart/form-data. Passing a plain JSON body with a file path will not work.
TypeScript types — PocketBase doesn’t ship collection types by default. Use pocketbase-typegen to auto-generate types from your schema: npx pocketbase-typegen --db ./pb_data/data.db --out pocketbase-types.ts.
Database locked errors under load — SQLite serializes writes. If you see SQLITE_BUSY in the logs, the bottleneck is concurrent writes, not PocketBase itself. Enable WAL mode (PocketBase does this by default in recent versions), shorten transactions in custom hooks, and avoid holding the JavaScript hook open while doing slow work. If the workload is write-heavy, this is a sign you should migrate to Postgres-backed Supabase rather than scale PocketBase vertically.
Schema changes wiping data on deploy — running migrate up against a fresh pb_data directory recreates the schema but starts empty. The deployment script must mount or restore the pb_data volume before starting PocketBase. On Fly.io, Railway, and similar platforms, attach a persistent volume to /pb_data and verify it survives a redeploy.
Custom hooks throwing “undefined is not a function” — PocketBase JavaScript hooks run on the embedded Goja runtime, which does not include the full Node.js standard library. Modules like fs, path, and crypto are not available unless you import the PocketBase-provided equivalents ($os, $filepath, $security). Stick to the documented $app, $http, and $security APIs.
For related backend issues, see Fix: Supabase Not Working, Fix: Drizzle ORM Not Working, Fix: Strapi Not Working, and Fix: Payload CMS Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Neon Database Not Working — Connection Timeout, Branching Errors, or Serverless Driver Issues
How to fix Neon Postgres issues — connection string setup, serverless HTTP driver vs TCP, database branching, connection pooling, Drizzle and Prisma integration, and cold start optimization.
Fix: Turso Not Working — Connection Refused, Queries Returning Empty, or Embedded Replicas Not Syncing
How to fix Turso database issues — libsql client setup, connection URLs and auth tokens, embedded replicas for local-first apps, schema migrations, Drizzle ORM integration, and edge deployment.
Fix: Upstash Not Working — Redis Commands Failing, Rate Limiter Not Blocking, or QStash Messages Lost
How to fix Upstash issues — Redis REST client setup, rate limiting with @upstash/ratelimit, QStash message queues, Kafka topics, Vector search, and edge runtime integration.
Fix: Convex Not Working — Query Not Updating, Mutation Throwing Validation Error, or Action Timing Out
How to fix Convex backend issues — query/mutation/action patterns, schema validation, real-time reactivity, file storage, auth integration, and common TypeScript type errors.