
Pocketbase Best Practices
- 581 installs
- 10 repo stars
- Updated April 8, 2026
- greendesertsnow/pocketbase-skills
Follow 63 prioritized PocketBase v0.36+ rules for collection design, API rules, auth, SDK usage, query optimization, realtime, files, and deployment.
About
Provides PocketBase best-practice rules across nine categories covering schema design, API-rule security, authentication, SDK usage, query optimization, realtime, and deployment. A developer uses it when building or optimizing a PocketBase backend.
- 63 impact-prioritized rules for PocketBase v0.36+
- Covers collection design, API rules, auth, and realtime
Pocketbase Best Practices by the numbers
- 581 all-time installs (skills.sh)
- Ranked #704 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/greendesertsnow/pocketbase-skills --skill pocketbase-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 581 |
|---|---|
| repo stars | ★ 10 |
| Last updated | April 8, 2026 |
| Repository | greendesertsnow/pocketbase-skills ↗ |
What it does
Follow 63 prioritized PocketBase v0.36+ rules for collection design, API rules, auth, SDK usage, query optimization, realtime, files, and deployment.
Files
PocketBase Best Practices
63 rules across 9 categories for PocketBase v0.36+, prioritized by impact.
Categories by Priority
| Priority | Category | Impact | Rules |
|---|---|---|---|
| 1 | Collection Design | CRITICAL | coll-field-types, coll-auth-vs-base, coll-relations, coll-indexes, coll-view-collections, coll-geopoint |
| 2 | API Rules & Security | CRITICAL | rules-basics, rules-filter-syntax, rules-request-context, rules-cross-collection, rules-locked-vs-open, rules-strftime |
| 3 | Authentication | CRITICAL | auth-password, auth-oauth2, auth-otp, auth-token-management, auth-mfa, auth-impersonation |
| 4 | SDK Usage | HIGH | sdk-initialization, sdk-auth-store, sdk-error-handling, sdk-auto-cancellation, sdk-filter-binding, sdk-field-modifiers, sdk-send-hooks |
| 5 | Query Performance | HIGH | query-pagination, query-expand, query-field-selection, query-batch-operations, query-n-plus-one, query-first-item, query-back-relations |
| 6 | Realtime | MEDIUM | realtime-subscribe, realtime-events, realtime-auth, realtime-reconnection |
| 7 | File Handling | MEDIUM | file-upload, file-serving, file-validation |
| 8 | Production & Deployment | MEDIUM | deploy-backup, deploy-configuration, deploy-reverse-proxy, deploy-sqlite-considerations, deploy-rate-limiting, deploy-scaling |
| 9 | Server-Side Extending | HIGH | ext-go-setup, ext-js-setup, ext-hooks-chain, ext-hooks-record-vs-request, ext-routing-custom, ext-transactions, ext-filter-binding-server, ext-filesystem, ext-cron-jobs, ext-go-migrations, ext-js-migrations, ext-mailer, ext-settings, ext-testing, ext-compose-request-flow, ext-go-custom-sqlite, ext-jsvm-scope, ext-jsvm-modules |
Quick Reference
Collection Design (CRITICAL)
- coll-field-types: Use appropriate field types (json for objects, select for enums)
- coll-auth-vs-base: Extend auth collection for users, base for non-auth data
- coll-relations: Use relation fields, not manual ID strings
- coll-indexes: Create indexes on frequently filtered/sorted fields
- coll-view-collections: Use views for complex aggregations
- coll-geopoint: Store coordinates as json field with lat/lng
API Rules (CRITICAL)
- rules-basics: Always set API rules; empty = public access
- rules-filter-syntax: Use @request.auth, @collection, @now in rules
- rules-request-context: Access request data via @request.body, @request.query;
@request.contextvalues:default/oauth2/otp/password/realtime/protectedFile - rules-cross-collection: Use @collection.name.field for cross-collection checks
- rules-locked-vs-open: Start locked, open selectively
- rules-strftime: Use
strftime('%Y-%m-%d', created)for date arithmetic (v0.36+)
Authentication (CRITICAL)
- auth-password: Use authWithPassword for email/password login
- auth-oauth2: Configure OAuth2 providers via Admin UI
- auth-otp: Two-step
requestOTP→authWithOTP; rate-limit requestOTP and never leak email existence - auth-token-management: Store tokens securely, refresh before expiry
- auth-mfa: Enable MFA for sensitive applications
- auth-impersonation: Use impersonation for admin actions on behalf of users
SDK Usage (HIGH)
- sdk-initialization: Initialize client once, reuse instance
- sdk-auth-store: Use AsyncAuthStore for React Native/SSR
- sdk-error-handling: Catch ClientResponseError, check status codes
- sdk-auto-cancellation: Disable auto-cancel for concurrent requests
- sdk-filter-binding: Use filter binding to prevent injection
Query Performance (HIGH)
- query-expand: Expand relations to avoid N+1 queries
- query-field-selection: Select only needed fields
- query-pagination: Use cursor pagination for large datasets
- query-batch-operations: Batch creates/updates when possible
Realtime (MEDIUM)
- realtime-subscribe: Subscribe to specific records or collections
- realtime-events: Handle create, update, delete events separately
- realtime-auth: Realtime respects API rules automatically
- realtime-reconnection: Implement reconnection logic
File Handling (MEDIUM)
- file-upload: Use FormData for uploads, set proper content types
- file-serving: Use pb.files.getURL() for file URLs
- file-validation: Validate file types and sizes server-side
Deployment (MEDIUM)
- deploy-backup: Schedule regular backups of pb_data
- deploy-configuration: Use environment variables for config
- deploy-reverse-proxy: Put behind nginx/caddy in production
- deploy-sqlite-considerations: Optimize SQLite for production workloads
- deploy-rate-limiting: Enable the built-in rate limiter (fixed-window as of v0.36.7); front with Nginx/Caddy for defense in depth
- deploy-scaling: Raise
ulimit -nfor realtime, setGOMEMLIMIT, enable settings encryption
Server-Side Extending (HIGH)
- ext-go-setup: Use
app.OnServe()to register routes; usee.Appinside hooks, not the parent-scope app - ext-js-setup: Drop
*.pb.jsinpb_hooks/; add/// <reference path="../pb_data/types.d.ts" /> - ext-hooks-chain: Always call
e.Next()/e.next(); useBindwith an Id for laterUnbind - ext-hooks-record-vs-request: Use
OnRecordEnrichto shape responses (incl. realtime);OnRecordRequestfor HTTP-only - ext-routing-custom: Namespace routes under
/api/{yourapp}/; attachRequireAuth()middleware - ext-transactions: Use the scoped
txAppinsideRunInTransaction; never capture the outerapp - ext-filter-binding-server: Bind user input with
{:name}+dbx.ParamsinFindFirstRecordByFilter/FindRecordsByFilter - ext-filesystem:
defer fs.Close()on everyNewFilesystem()/NewBackupsFilesystem()handle - ext-cron-jobs: Register with
app.Cron().MustAdd(id, expr, fn)/cronAdd(); stable ids, no__pb*__prefix - ext-go-migrations: Versioned
.gofiles undermigrations/;Automigrate: osutils.IsProbablyGoRun() - ext-js-migrations:
pb_migrations/<unix>_*.jswithmigrate(upFn, downFn); auto-discovered by filename - ext-mailer: Resolve sender from
app.Settings().Metaat send-time; never shipno-reply@example.com; create the mail client per send - ext-settings: Read via
app.Settings()at call time; setPB_ENCRYPTION(32 chars) to encrypt_paramsat rest - ext-testing:
tests.NewTestApp(testDataDir)+tests.ApiScenario;defer app.Cleanup(), assertExpectedEvents - ext-compose-request-flow: Composite walkthrough showing which app instance is active at each layer (route → tx → hook → enrich)
- ext-go-custom-sqlite: Only use
DBConnectwhen you need FTS5/ICU;DBConnectis called twice (data.db + auxiliary.db) - ext-jsvm-scope: Variables outside handlers are undefined at runtime — load shared config via
require()inside the handler - ext-jsvm-modules: Only CJS (
require()) works in goja; bundle ESM first; avoid mutable module state
Example Prompts
Try these with your AI agent to see the skill in action:
Building a new feature:
- "Design a PocketBase schema for an e-commerce app with products, orders, and reviews"
- "Implement OAuth2 login with Google and GitHub for my app"
- "Build a real-time notification system with PocketBase subscriptions"
- "Create a file upload form with image validation and thumbnail previews"
Fixing issues:
- "My list query is slow on 100k records -- optimize it"
- "I'm getting 403 errors on my batch operations"
- "Fix the N+1 query problem in my posts list that loads author data in a loop"
- "My realtime subscriptions stop working after a few minutes"
Security review:
- "Review my API rules -- users should only access their own data"
- "Set up proper access control: admins manage all content, users edit only their own"
- "Are my authentication cookies configured securely for SSR?"
- "Audit my collection rules for IDOR vulnerabilities"
Going to production:
- "Configure Nginx with HTTPS, rate limiting, and security headers for PocketBase"
- "Set up automated backups for my PocketBase database"
- "Optimize SQLite settings for a production workload with ~500 concurrent users"
- "Deploy PocketBase with Docker Compose and Caddy"
Extending PocketBase:
- "Add a custom Go route that sends a Slack notification after a record is created"
- "Write a pb_hooks script that validates an email domain before user signup"
- "Set up FTS5 full-text search with a custom SQLite driver in my Go app"
- "Share a config object across multiple pb_hooks files without race conditions"
Detailed Rules
Load the relevant category for complete rule documentation with code examples:
- Collection Design - Schema patterns, field types, relations, indexes
- API Rules & Security - Access control, filter expressions, security patterns
- Authentication - Password auth, OAuth2, MFA, token management
- SDK Usage - Client initialization, auth stores, error handling, hooks
- Query Performance - Pagination, expansion, batch operations, N+1 prevention
- Realtime - SSE subscriptions, event handling, reconnection
- File Handling - Uploads, serving, validation
- Production & Deployment - Backup, configuration, reverse proxy, SQLite optimization
- Server-Side Extending - Go/JSVM setup, event hooks, custom routes, modules, custom SQLite
PocketBase Best Practices
Version 1.3.0 Community April 2026
Comprehensive PocketBase development best practices and performance optimization guide. Contains 63 rules across 9 categories, prioritized by impact from critical (collection design, API rules, authentication) to incremental (production deployment). Includes server-side extending rules for Go and JavaScript (JSVM): event hooks, custom routing, transactions with scoped txApp, server-side filter binding, filesystem handling, cron job scheduling, and Go schema migrations. Updated for PocketBase v0.36.8 and JS SDK v0.26.8 (getFullList default batch size 1000, authWithOAuth2 cancellation fix, getURL null param handling, v0.36.7 fixed-window rate limiter, v0.36.0 strftime() filter function, OTP auth flow). Each rule includes detailed explanations, incorrect vs. correct code examples, and specific guidance to help AI agents generate better PocketBase code.
---
Categories
Detailed rules are split by category. Load only the relevant file:
1. Collection Design - CRITICAL
Schema design, field types, relations, indexes, and collection type selection. Foundation for application architecture and long-term maintainability.
- 1.1 Use Auth Collections for User Accounts
- 1.2 Choose Appropriate Field Types for Your Data
- 1.3 Use GeoPoint Fields for Location Data
- 1.4 Create Indexes for Frequently Filtered Fields
- 1.5 Configure Relations with Proper Cascade Options
- 1.6 Use View Collections for Complex Read-Only Queries
2. API Rules & Security - CRITICAL
Access control rules, filter expressions, request context usage, and security patterns. Critical for protecting data and enforcing authorization.
- 2.1 Understand API Rule Types and Defaults
- 2.2 Use @collection for Cross-Collection Lookups
- 2.3 Master Filter Expression Syntax
- 2.4 Default to Locked Rules, Open Explicitly
- 2.5 Use @request Context in API Rules
- 2.6 Use strftime() for Date Arithmetic in Filter Expressions
3. Authentication - CRITICAL
Password authentication, OAuth2 integration, token management, MFA setup, and auth collection configuration.
- 3.1 Use Impersonation for Admin Operations
- 3.2 Implement Multi-Factor Authentication
- 3.3 Integrate OAuth2 Providers Correctly
- 3.4 Use authWithOTP for Email One-Time Codes, Rate-Limit requestOTP
- 3.5 Implement Secure Password Authentication
- 3.6 Manage Auth Tokens Properly
4. SDK Usage - HIGH
JavaScript SDK initialization, auth store patterns, error handling, request cancellation, and safe parameter binding.
- 4.1 Use Appropriate Auth Store for Your Platform
- 4.2 Understand and Control Auto-Cancellation
- 4.3 Handle SDK Errors Properly
- 4.4 Use Field Modifiers for Incremental Updates
- 4.5 Use Safe Parameter Binding in Filters
- 4.6 Initialize PocketBase Client Correctly
- 4.7 Use Send Hooks for Request Customization
5. Query Performance - HIGH
Pagination strategies, relation expansion, field selection, batch operations, and N+1 query prevention.
- 5.1 Use Back-Relations for Inverse Lookups
- 5.2 Use Batch Operations for Multiple Writes
- 5.3 Expand Relations Efficiently
- 5.4 Select Only Required Fields
- 5.5 Use getFirstListItem for Single Record Lookups
- 5.6 Prevent N+1 Query Problems
- 5.7 Use Efficient Pagination Strategies
6. Realtime - MEDIUM
SSE subscriptions, event handling, connection management, and authentication with realtime.
- 6.1 Authenticate Realtime Connections
- 6.2 Handle Realtime Events Properly
- 6.3 Handle Realtime Connection Issues
- 6.4 Implement Realtime Subscriptions Correctly
7. File Handling - MEDIUM
File uploads, URL generation, thumbnail creation, and validation patterns.
- 7.1 Generate File URLs Correctly
- 7.2 Upload Files Correctly
- 7.3 Validate File Uploads
8. Production & Deployment - LOW-MEDIUM
Backup strategies, configuration management, reverse proxy setup, and SQLite optimization.
- 8.1 Implement Proper Backup Strategies
- 8.2 Configure Production Settings Properly
- 8.3 Enable Rate Limiting for API Protection
- 8.4 Configure Reverse Proxy Correctly
- 8.5 Tune OS and Runtime for PocketBase Scale
- 8.6 Optimize SQLite for Production
9. Server-Side Extending - HIGH
Extending PocketBase with Go or embedded JavaScript (JSVM) - event hooks, custom routes, transactions, cron jobs, filesystem, migrations, and safe server-side filter binding.
- 9.1 Compose Hooks, Transactions, Routing, and Enrich in One Request Flow
- 9.2 Schedule Recurring Jobs with the Builtin Cron Scheduler
- 9.3 Always Close the Filesystem Handle Returned by NewFilesystem
- 9.4 Bind User Input in Server-Side Filters with {:placeholder} Params
- 9.5 Use DBConnect Only When You Need a Custom SQLite Driver
- 9.6 Version Your Schema with Go Migrations
- 9.7 Set Up a Go-Extended PocketBase Application
- 9.8 Always Call e.Next() and Use e.App Inside Hook Handlers
- 9.9 Pick the Right Record Hook - Model vs Request vs Enrich
- 9.10 Write JSVM Migrations as pb_migrations/*.js Files
- 9.11 Set Up JSVM (pb_hooks) for Server-Side JavaScript
- 9.12 Load Shared Code with CommonJS require() in pb_hooks
- 9.13 Avoid Capturing Variables Outside JSVM Handler Scope
- 9.14 Send Email via app.NewMailClient, Never the Default example.com Sender
- 9.15 Register Custom Routes Safely with Built-in Middlewares
- 9.16 Read Settings via app.Settings(), Encrypt at Rest with PB_ENCRYPTION
- 9.17 Test Hooks and Routes with tests.NewTestApp and ApiScenario
- 9.18 Use RunInTransaction with the Scoped txApp, Never the Outer App
---
References
- https://pocketbase.io/docs/
- https://github.com/pocketbase/pocketbase
- https://github.com/pocketbase/js-sdk
- https://pocketbase.io/docs/api-records/
- https://pocketbase.io/docs/api-rules-and-filters/
- https://pocketbase.io/docs/go-overview/
- https://pocketbase.io/docs/js-overview/
- https://pocketbase.io/docs/go-migrations/
- https://pocketbase.io/docs/go-jobs-scheduling/
- https://pocketbase.io/docs/js-jobs-scheduling/
- https://pocketbase.io/docs/going-to-production/
{
"version": "1.3.0",
"organization": "Community",
"date": "April 2026",
"abstract": "Comprehensive PocketBase development best practices and performance optimization guide. Contains 63 rules across 9 categories, prioritized by impact from critical (collection design, API rules, authentication) to incremental (production deployment). Includes server-side extending rules for Go and JavaScript (JSVM): event hooks, custom routing, transactions with scoped txApp, server-side filter binding, filesystem handling, cron job scheduling, and Go schema migrations. Updated for PocketBase v0.36.8 and JS SDK v0.26.8 (getFullList default batch size 1000, authWithOAuth2 cancellation fix, getURL null param handling, v0.36.7 fixed-window rate limiter, v0.36.0 strftime() filter function, OTP auth flow). Each rule includes detailed explanations, incorrect vs. correct code examples, and specific guidance to help AI agents generate better PocketBase code.",
"references": [
"https://pocketbase.io/docs/",
"https://github.com/pocketbase/pocketbase",
"https://github.com/pocketbase/js-sdk",
"https://pocketbase.io/docs/api-records/",
"https://pocketbase.io/docs/api-rules-and-filters/",
"https://pocketbase.io/docs/go-overview/",
"https://pocketbase.io/docs/js-overview/",
"https://pocketbase.io/docs/go-migrations/",
"https://pocketbase.io/docs/go-jobs-scheduling/",
"https://pocketbase.io/docs/js-jobs-scheduling/",
"https://pocketbase.io/docs/going-to-production/"
]
}
API Rules & Security
Impact: CRITICAL
Access control rules, filter expressions, request context usage, and security patterns. Critical for protecting data and enforcing authorization.
---
1. Understand API Rule Types and Defaults
Impact: CRITICAL (Prevents unauthorized access, data leaks, and security vulnerabilities)
PocketBase uses five collection-level rules to control access. Understanding the difference between locked (null), open (""), and expression rules is critical for security.
Incorrect (leaving rules open unintentionally):
// Collection with overly permissive rules
const collection = {
name: 'messages',
listRule: '', // Anyone can list all messages!
viewRule: '', // Anyone can view any message!
createRule: '', // Anyone can create messages!
updateRule: '', // Anyone can update any message!
deleteRule: '' // Anyone can delete any message!
};
// Complete security bypass - all data exposedCorrect (explicit, restrictive rules):
// Collection with proper access control
const collection = {
name: 'messages',
// null = locked, only superusers can access
listRule: null, // Default: locked to superusers
// '' (empty string) = open to everyone (use sparingly)
viewRule: '@request.auth.id != ""', // Any authenticated user
// Expression = conditional access
createRule: '@request.auth.id != ""', // Must be logged in
updateRule: 'author = @request.auth.id', // Only author
deleteRule: 'author = @request.auth.id' // Only author
};Rule types explained:
| Rule Value | Meaning | Use Case |
|---|---|---|
null | Locked (superusers only) | Admin-only data, system tables |
'' (empty string) | Open to everyone | Public content, no auth required |
'expression' | Conditional access | Most common - check auth, ownership |
Common patterns:
// Public read, authenticated write (enforce ownership on create)
listRule: '',
viewRule: '',
createRule: '@request.auth.id != "" && @request.body.author = @request.auth.id',
updateRule: 'author = @request.auth.id',
deleteRule: 'author = @request.auth.id'
// Private to owner only
listRule: 'owner = @request.auth.id',
viewRule: 'owner = @request.auth.id',
createRule: '@request.auth.id != ""',
updateRule: 'owner = @request.auth.id',
deleteRule: 'owner = @request.auth.id'
// Read-only public data
listRule: '',
viewRule: '',
createRule: null,
updateRule: null,
deleteRule: nullError responses by rule type:
- List rule fail: 200 with empty items
- View/Update/Delete fail: 404 (hides existence)
- Create fail: 400
- Locked rule violation: 403
Reference: PocketBase API Rules
2. Use @collection for Cross-Collection Lookups
Impact: HIGH (Enables complex authorization without denormalization)
The @collection reference allows rules to query other collections, enabling complex authorization patterns like role-based access, team membership, and resource permissions.
Incorrect (denormalizing data for access control):
// Duplicating team membership in every resource
const documentsSchema = [
{ name: 'title', type: 'text' },
{ name: 'team', type: 'relation' },
// Duplicated member list for access control - gets out of sync!
{ name: 'allowedUsers', type: 'relation', options: { maxSelect: 999 } }
];
// Rule checks duplicated data
listRule: 'allowedUsers ?= @request.auth.id'
// Problem: must update allowedUsers whenever team membership changesCorrect (using @collection lookup):
// Clean schema - no duplication
const documentsSchema = [
{ name: 'title', type: 'text' },
{ name: 'team', type: 'relation', options: { collectionId: 'teams' } }
];
// Check team membership via @collection lookup
listRule: '@collection.team_members.user ?= @request.auth.id && @collection.team_members.team ?= team'
// Alternative: check if user is in team's members array
listRule: 'team.members ?= @request.auth.id'
// Role-based access via separate roles collection
listRule: '@collection.user_roles.user = @request.auth.id && @collection.user_roles.role = "admin"'Common patterns:
// Team-based access
// teams: { name, members (relation to users) }
// documents: { title, team (relation to teams) }
viewRule: 'team.members ?= @request.auth.id'
// Organization hierarchy
// orgs: { name }
// org_members: { org, user, role }
// projects: { name, org }
listRule: '@collection.org_members.org = org && @collection.org_members.user = @request.auth.id'
// Permission-based access
// permissions: { resource, user, level }
updateRule: '@collection.permissions.resource = id && @collection.permissions.user = @request.auth.id && @collection.permissions.level = "write"'
// Using aliases for complex queries
listRule: '@collection.memberships:m.user = @request.auth.id && @collection.memberships:m.team = team'Performance considerations:
- Cross-collection lookups add query complexity
- Ensure referenced fields are indexed
- Consider caching for frequently accessed permissions
- Test performance with realistic data volumes
Reference: PocketBase Collection Reference
3. Master Filter Expression Syntax
Impact: CRITICAL (Enables complex access control and efficient querying)
PocketBase filter expressions use a specific syntax for both API rules and client-side queries. Understanding operators and composition is essential.
Incorrect (invalid filter syntax):
// Wrong operator syntax
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'status == "published"' // Wrong: == instead of =
});
// Missing quotes around strings
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'status = published' // Wrong: unquoted string
});
// Wrong boolean logic
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'status = "published" AND featured = true' // Wrong: AND instead of &&
});Correct (proper filter syntax):
// Equality and comparison operators
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'status = "published"' // Equals
});
filter: 'views != 0' // Not equals
filter: 'views > 100' // Greater than
filter: 'views >= 100' // Greater or equal
filter: 'price < 50.00' // Less than
filter: 'created <= "2024-01-01 00:00:00"' // Less or equal
// String operators
filter: 'title ~ "hello"' // Contains (case-insensitive)
filter: 'title !~ "spam"' // Does not contain
// Logical operators
filter: 'status = "published" && featured = true' // AND
filter: 'category = "news" || category = "blog"' // OR
filter: '(status = "draft" || status = "review") && author = "abc"' // Grouping
// Array/multi-value operators (for select, relation fields)
filter: 'tags ?= "featured"' // Any tag equals "featured"
filter: 'tags ?~ "tech"' // Any tag contains "tech"
// Null checks
filter: 'deletedAt = null' // Is null
filter: 'avatar != null' // Is not null
// Date comparisons
filter: 'created > "2024-01-01 00:00:00"'
filter: 'created >= @now' // Current timestamp
filter: 'expires < @today' // Start of today (UTC)Available operators:
| Operator | Description |
|---|---|
= | Equal |
!= | Not equal |
> >= < <= | Comparison |
~ | Contains (LIKE %value%) |
!~ | Does not contain |
?= ?!= ?> ?~ | Any element matches |
&& | AND |
| `\ | \ |
() | Grouping |
Date macros:
@now- Current UTC datetime@today- Start of today UTC@month- Start of current month UTC@year- Start of current year UTC
Filter functions:
strftime(fmt, datetime)- Format/extract datetime parts (v0.36+). E.g.strftime('%Y-%m', created) = "2026-03". Seerules-strftime.mdfor the full format specifier list.length(field)- Element count of a multi-value field (file, relation, select). E.g.length(tags) > 0.each(field, expr)- Iterate a multi-value field:each(tags, ? ~ "urgent").issetIf(field, val)- Conditional presence check for complex rules.
Reference: PocketBase Filters
4. Default to Locked Rules, Open Explicitly
Impact: CRITICAL (Defense in depth, prevents accidental data exposure)
New collections should start with locked (null) rules and explicitly open only what's needed. This prevents accidental data exposure and follows the principle of least privilege.
Incorrect (starting with open rules):
// Dangerous: copying rules from examples without thinking
const collection = {
name: 'user_settings',
listRule: '', // Open - leaks all user settings!
viewRule: '', // Open - anyone can view any setting
createRule: '', // Open - no auth required
updateRule: '', // Open - anyone can modify!
deleteRule: '' // Open - anyone can delete!
};
// Also dangerous: using auth check when ownership needed
const collection = {
name: 'private_notes',
listRule: '@request.auth.id != ""', // Any logged-in user sees ALL notes
viewRule: '@request.auth.id != ""',
updateRule: '@request.auth.id != ""', // Any user can edit ANY note!
};Correct (locked by default, explicitly opened):
// Step 1: Start locked
const collection = {
name: 'user_settings',
listRule: null, // Locked - superusers only
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null
};
// Step 2: Open only what's needed with proper checks
const collection = {
name: 'user_settings',
// Users can only see their own settings
listRule: 'user = @request.auth.id',
viewRule: 'user = @request.auth.id',
// Users can only create settings for themselves
createRule: '@request.auth.id != "" && @request.body.user = @request.auth.id',
// Users can only update their own settings
updateRule: 'user = @request.auth.id',
// Prevent deletion or restrict to owner
deleteRule: 'user = @request.auth.id'
};
// For truly public data, document why it's open
const collection = {
name: 'public_announcements',
// Intentionally public - these are site-wide announcements
listRule: '',
viewRule: '',
// Only admins can manage (using custom "role" field on auth collection)
// IMPORTANT: Prevent role self-assignment in the users collection updateRule:
// updateRule: 'id = @request.auth.id && @request.body.role:isset = false'
createRule: '@request.auth.role = "admin"',
updateRule: '@request.auth.role = "admin"',
deleteRule: '@request.auth.role = "admin"'
};Rule development workflow:
1. Start locked - All rules null 2. Identify access needs - Who needs what access? 3. Write minimal rules - Open only required operations 4. Test thoroughly - Verify both allowed and denied cases 5. Document decisions - Comment why rules are set as they are
Security checklist:
- [ ] No empty string rules without justification
- [ ] Ownership checks on personal data
- [ ] Auth checks on write operations
- [ ] Admin-only rules for sensitive operations
- [ ] Tested with different user contexts
Reference: PocketBase API Rules
5. Use @request Context in API Rules
Impact: CRITICAL (Enables dynamic, user-aware access control)
The @request object provides access to the current request context including authenticated user, request body, query parameters, and headers. Use it to build dynamic access rules.
Incorrect (hardcoded or missing auth checks):
// No authentication check
const collection = {
listRule: '', // Anyone can see everything
createRule: '' // Anyone can create
};
// Hardcoded user ID (never do this)
const collection = {
listRule: 'owner = "specific_user_id"' // Only one user can access
};Correct (using @request context):
// Check if user is authenticated
createRule: '@request.auth.id != ""'
// Check ownership via auth record
listRule: 'owner = @request.auth.id'
viewRule: 'owner = @request.auth.id'
updateRule: 'owner = @request.auth.id'
deleteRule: 'owner = @request.auth.id'
// Access auth record fields
// IMPORTANT: If using custom role fields, ensure update rules prevent
// users from modifying their own role: @request.body.role:isset = false
listRule: '@request.auth.role = "admin"'
listRule: '@request.auth.verified = true'
// Validate request body on create/update
createRule: '@request.auth.id != "" && @request.body.owner = @request.auth.id'
// Prevent changing certain fields
updateRule: 'owner = @request.auth.id && @request.body.owner:isset = false'
// WARNING: Query parameters are user-controlled and should NOT be used
// for authorization decisions. Use them only for optional filtering behavior
// where the fallback is equally safe.
// listRule: '@request.query.publicOnly = "true" || owner = @request.auth.id'
// The above is UNSAFE - users can bypass ownership by adding ?publicOnly=true
// Instead, use separate endpoints or server-side logic for public vs. private views.
listRule: 'owner = @request.auth.id || public = true' // Use a record field, not query param
// Access nested auth relations
listRule: 'team.members ?= @request.auth.id'Available @request fields:
| Field | Description |
|---|---|
@request.auth.id | Authenticated user's ID (empty string if not authenticated) |
@request.auth.* | Any field from auth record (role, verified, email, etc.) |
@request.body.* | Request body fields (create/update only) |
@request.query.* | URL query parameters |
@request.headers.* | Request headers |
@request.method | HTTP method (GET, POST, etc.) |
@request.context | Request context: default, oauth2, otp, password, realtime, protectedFile |
Body field modifiers:
// Check if field is being set
updateRule: '@request.body.status:isset = false' // Can't change status
// Check if field changed from current value
updateRule: '@request.body.owner:changed = false' // Can't change owner
// Get length of array/string
createRule: '@request.body.tags:length <= 5' // Max 5 tagsReference: PocketBase API Rules
6. Use strftime() for Date Arithmetic in Filter Expressions
Impact: MEDIUM (strftime() (added in v0.36) replaces brittle string prefix comparisons on datetime fields)
PocketBase v0.36 added the strftime() function to the filter expression grammar. It maps directly to SQLite's strftime and is the correct way to bucket, compare, or extract parts of a datetime field. Before v0.36 people worked around this with ~ (substring) matches against the ISO string; those workarounds are fragile (they break at midnight UTC, ignore timezones, and can't handle ranges).
Incorrect (substring match on the ISO datetime string):
// ❌ "matches anything whose ISO string contains 2026-04-08" - breaks as soon
// as your DB stores sub-second precision or you cross a month boundary
const todayPrefix = new Date().toISOString().slice(0, 10);
const results = await pb.collection("orders").getList(1, 50, {
filter: `created ~ "${todayPrefix}"`, // ❌
});Correct (strftime with named format specifiers):
// "all orders created today (UTC)"
const results = await pb.collection("orders").getList(1, 50, {
filter: `strftime('%Y-%m-%d', created) = strftime('%Y-%m-%d', @now)`,
});
// "all orders from March 2026"
await pb.collection("orders").getList(1, 50, {
filter: `strftime('%Y-%m', created) = "2026-03"`,
});
// "orders created this hour"
await pb.collection("orders").getList(1, 50, {
filter: `strftime('%Y-%m-%d %H', created) = strftime('%Y-%m-%d %H', @now)`,
});// Same function is available inside API rules:
// collection "orders" - List rule:
// @request.auth.id != "" &&
// user = @request.auth.id &&
// strftime('%Y-%m-%d', created) = strftime('%Y-%m-%d', @now)Common format specifiers:
| Specifier | Meaning |
|---|---|
%Y | 4-digit year |
%m | month (01-12) |
%d | day of month (01-31) |
%H | hour (00-23) |
%M | minute (00-59) |
%S | second (00-59) |
%W | ISO week (00-53) |
%j | day of year (001-366) |
%w | day of week (0=Sunday) |
Other filter functions worth knowing:
| Function | Use |
|---|---|
strftime(fmt, datetime) | Format/extract datetime parts (v0.36+) |
length(field) | Count elements in a multi-value field (file, relation, select) |
each(field, expr) | Iterate over multi-value fields: each(tags, ? ~ "urgent") |
issetIf(field, val) | Conditional presence check used in complex rules |
Reference: Filter Syntax - Functions · v0.36.0 release
Authentication
Impact: CRITICAL
Password authentication, OAuth2 integration, token management, MFA setup, and auth collection configuration.
---
1. Use Impersonation for Admin Operations
Impact: MEDIUM (Safe admin access to user data without password sharing)
Impersonation allows superusers to generate tokens for other users, enabling admin support tasks and API key functionality without sharing passwords.
Incorrect (sharing credentials or bypassing auth):
// Bad: sharing user passwords for support
async function helpUser(userId, userPassword) {
await pb.collection('users').authWithPassword(userEmail, userPassword);
// Support team knows user's password!
}
// Bad: directly modifying records without proper context
async function fixUserData(userId) {
// Bypasses user's perspective and rules
await pb.collection('posts').update(postId, { fixed: true });
}Correct (using impersonation):
import PocketBase from 'pocketbase';
// Admin client with superuser auth (use environment variables, never hardcode)
const adminPb = new PocketBase(process.env.PB_URL);
await adminPb.collection('_superusers').authWithPassword(
process.env.PB_SUPERUSER_EMAIL,
process.env.PB_SUPERUSER_PASSWORD
);
async function impersonateUser(userId) {
// Generate impersonation token (non-renewable)
const impersonatedClient = await adminPb
.collection('users')
.impersonate(userId, 3600); // 1 hour duration
// impersonatedClient has user's auth context
console.log('Acting as:', impersonatedClient.authStore.record.email);
// Operations use user's permissions
const userPosts = await impersonatedClient.collection('posts').getList();
return impersonatedClient;
}
// Use case: Admin viewing user's data
async function adminViewUserPosts(userId) {
const userClient = await impersonateUser(userId);
// See exactly what the user sees (respects API rules)
const posts = await userClient.collection('posts').getList();
return posts;
}
// Use case: API keys for server-to-server communication
async function createApiKey(serviceUserId) {
// Create a service impersonation token (use short durations, rotate regularly)
const serviceClient = await adminPb
.collection('service_accounts')
.impersonate(serviceUserId, 86400); // 24 hours max, rotate via scheduled task
// Return token for service to use
return serviceClient.authStore.token;
}
// Using API key token in another service
async function useApiKey(apiToken) {
const pb = new PocketBase('http://127.0.0.1:8090');
// Manually set the token
pb.authStore.save(apiToken, null);
// Now requests use the service account's permissions
const data = await pb.collection('data').getList();
return data;
}Important considerations:
// Impersonation tokens are non-renewable
const client = await adminPb.collection('users').impersonate(userId, 3600);
// This will fail - can't refresh impersonation tokens
try {
await client.collection('users').authRefresh();
} catch (error) {
// Expected: impersonation tokens can't be refreshed
}
// For continuous access, generate new token when needed
async function getImpersonatedClient(userId) {
// Check if existing token is still valid
if (cachedClient?.authStore.isValid) {
return cachedClient;
}
// Generate fresh token
return await adminPb.collection('users').impersonate(userId, 3600);
}Security best practices:
- Use short durations for support tasks
- Log all impersonation events
- Restrict impersonation to specific admin roles
- Never expose impersonation capability in client code
- Use dedicated service accounts for API keys
Reference: PocketBase Impersonation
2. Implement Multi-Factor Authentication
Impact: HIGH (Additional security layer for sensitive applications)
MFA requires users to authenticate with two different methods. PocketBase supports OTP (One-Time Password) via email as the second factor.
Incorrect (single-factor only for sensitive apps):
// Insufficient for sensitive applications
async function login(email, password) {
const authData = await pb.collection('users').authWithPassword(email, password);
// User immediately has full access - no second factor
return authData;
}Correct (MFA flow with OTP):
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
async function loginWithMFA(email, password) {
try {
// First factor: password
const authData = await pb.collection('users').authWithPassword(email, password);
// If MFA not required, auth succeeds immediately
return { success: true, authData };
} catch (error) {
// MFA required - returns 401 with mfaId
if (error.status === 401 && error.response?.mfaId) {
return {
success: false,
mfaRequired: true,
mfaId: error.response.mfaId
};
}
throw error;
}
}
async function requestOTP(email) {
// Request OTP to be sent via email
const result = await pb.collection('users').requestOTP(email);
// Returns otpId - needed to verify the OTP
// Note: Returns otpId even if email doesn't exist (prevents enumeration)
return result.otpId;
}
async function completeMFAWithOTP(mfaId, otpId, otpCode) {
try {
// Second factor: OTP verification
const authData = await pb.collection('users').authWithOTP(
otpId,
otpCode,
{ mfaId } // Include mfaId from first factor
);
return { success: true, authData };
} catch (error) {
if (error.status === 400) {
throw new Error('Invalid or expired code');
}
throw error;
}
}
// Complete flow example
async function fullMFAFlow(email, password, otpCode = null) {
// Step 1: Password authentication
const step1 = await loginWithMFA(email, password);
if (step1.success) {
return step1.authData; // MFA not required
}
if (step1.mfaRequired) {
// Step 2: Request OTP
const otpId = await requestOTP(email);
// Step 3: UI prompts user for OTP code...
// (In real app, wait for user input)
if (otpCode) {
// Step 4: Complete MFA
const step2 = await completeMFAWithOTP(step1.mfaId, otpId, otpCode);
return step2.authData;
}
return { pendingMFA: true, mfaId: step1.mfaId, otpId };
}
}Configure MFA (Admin UI or API):
// Enable MFA on auth collection (superuser only)
await pb.collections.update('users', {
mfa: {
enabled: true,
duration: 1800, // MFA session duration (30 min)
rule: '' // When to require MFA (empty = always for all users)
// rule: '@request.auth.role = "admin"' // Only for admins
},
otp: {
enabled: true,
duration: 300, // OTP validity (5 min)
length: 6, // OTP code length
emailTemplate: {
subject: 'Your verification code',
body: 'Your code is: {OTP}'
}
}
});MFA best practices:
- Always enable for admin accounts
- Consider making MFA optional for regular users
- Use short OTP durations (5-10 minutes)
- Implement rate limiting on OTP requests
- Log MFA events for security auditing
Reference: PocketBase MFA
3. Integrate OAuth2 Providers Correctly
Impact: CRITICAL (Secure third-party authentication with proper flow handling)
OAuth2 integration should use the all-in-one method for simplicity and security. Manual code exchange should only be used when necessary (e.g., mobile apps with deep links).
Incorrect (manual implementation without SDK):
// Don't manually handle OAuth flow
async function loginWithGoogle() {
// Redirect user to Google manually
window.location.href = 'https://accounts.google.com/oauth/authorize?...';
}
// Manual callback handling
async function handleCallback(code) {
// Exchange code manually - error prone!
const response = await fetch('/api/auth/callback', {
method: 'POST',
body: JSON.stringify({ code })
});
}Correct (using SDK's all-in-one method):
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// All-in-one OAuth2 (recommended for web apps)
async function loginWithOAuth2(providerName) {
try {
const authData = await pb.collection('users').authWithOAuth2({
provider: providerName, // 'google', 'github', 'microsoft', etc.
// Optional: create new user data if not exists
createData: {
emailVisibility: true,
name: '' // Will be populated from OAuth provider
}
});
console.log('Logged in via', providerName);
console.log('User:', authData.record.email);
console.log('Is new user:', authData.meta?.isNew);
return authData;
} catch (error) {
if (error.isAbort) {
console.log('OAuth popup was closed');
return null;
}
throw error;
}
}
// Usage
document.getElementById('google-btn').onclick = () => loginWithOAuth2('google');
document.getElementById('github-btn').onclick = () => loginWithOAuth2('github');Manual code exchange (for React Native / deep links):
// Only use when all-in-one isn't possible
async function loginWithOAuth2Manual() {
// Get auth methods - PocketBase provides state and codeVerifier
const authMethods = await pb.collection('users').listAuthMethods();
const provider = authMethods.oauth2.providers.find(p => p.name === 'google');
// Store the provider's state and codeVerifier for callback verification
// PocketBase generates these for you - don't create your own
sessionStorage.setItem('oauth_state', provider.state);
sessionStorage.setItem('oauth_code_verifier', provider.codeVerifier);
// Build the OAuth URL using provider.authURL + redirect
const redirectUrl = window.location.origin + '/oauth-callback';
const authUrl = provider.authURL + encodeURIComponent(redirectUrl);
// Redirect to OAuth provider
window.location.href = authUrl;
}
// In your callback handler (e.g., /oauth-callback page):
async function handleOAuth2Callback() {
const params = new URLSearchParams(window.location.search);
// CSRF protection: verify state matches
if (params.get('state') !== sessionStorage.getItem('oauth_state')) {
throw new Error('State mismatch - potential CSRF attack');
}
const code = params.get('code');
const codeVerifier = sessionStorage.getItem('oauth_code_verifier');
const redirectUrl = window.location.origin + '/oauth-callback';
// Exchange code for auth token
const authData = await pb.collection('users').authWithOAuth2Code(
'google',
code,
codeVerifier,
redirectUrl,
{ emailVisibility: true }
);
// Clean up
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('oauth_code_verifier');
return authData;
}Configure OAuth2 provider (Admin UI or API):
// Via API (superuser only) - usually done in Admin UI
// IMPORTANT: Never hardcode client secrets. Use environment variables.
await pb.collections.update('users', {
oauth2: {
enabled: true,
providers: [{
name: 'google',
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET
}],
mappedFields: {
avatarURL: 'avatar' // Map OAuth field to collection field
}
}
});Reference: PocketBase OAuth2
4. Use authWithOTP for Email One-Time Codes, Rate-Limit requestOTP
Impact: HIGH (OTP endpoints are unauthenticated; unthrottled requestOTP enables email bombing and enumeration)
Auth collections can enable OTP login from the admin UI (Collection → Options → "Enable OTP"). The client flow is two steps: requestOTP(email) returns an otpId, then authWithOTP(otpId, code) exchanges the id + code for an auth token. Two things trip people up: (1) the OTP response is the same whether the email exists or not - do not break that by leaking a distinct error; (2) requestOTP sends an email, so it must be rate-limited or an attacker can use it to spam any address.
Incorrect (leaks existence, custom requestOTP with no rate limit):
// ❌ Client-side existence check - ignore the 404 and expose it to the user
try {
await pb.collection("users").getFirstListItem(`email="${email}"`);
} catch (e) {
alert("No account with that email"); // ❌ account enumeration
return;
}
// ❌ Ad-hoc route with no rate limit - attacker hammers this to spam mailboxes
routerAdd("POST", "/api/myapp/otp", (e) => {
const body = e.requestInfo().body;
const user = $app.findAuthRecordByEmail("users", body.email);
// send custom email...
return e.json(200, { ok: true });
});Correct (use the built-in flow, step 1 always returns an otpId):
// Step 1: request the code. Always returns { otpId } - even if the email
// does not exist, PocketBase returns a synthetic id so enumeration is
// impossible. Treat every response as success from the UI perspective.
const { otpId } = await pb.collection("users").requestOTP("user@example.com");
// Step 2: exchange otpId + the 8-digit code the user typed
const authData = await pb.collection("users").authWithOTP(
otpId,
"12345678",
);
// pb.authStore is now populated// Go side - rate-limit and log if you wrap your own endpoint
app.OnRecordRequestOTPRequest("users").BindFunc(func(e *core.RecordRequestOTPRequestEvent) error {
// e.Collection, e.Record (may be nil - synthetic id path),
// e.Email (always present), e.Password (unused for OTP)
e.App.Logger().Info("otp requested",
"email", e.Email,
"ip", e.RequestInfo.Headers["x_forwarded_for"])
return e.Next() // REQUIRED
})Rules:
requestOTPalways returns 200 with an otpId, even for non-existent emails - preserve that by never adding a pre-check or a different error path.- Enable the built-in rate limiter (see
deploy-rate-limiting.md) and raise the cost for the*:requestOTPlabel. Without this, an attacker can email-bomb arbitrary users. - The OTP code is 8 digits by default, with a 3-minute TTL. Do not extend the TTL - short windows are the whole point.
authWithOTPconsumes the code; a successful call invalidates theotpId. Always show a generic "invalid or expired code" on failure.- If you want OTP without a password, set the collection's
Passwordoption to off andOTPon. If both are enabled, users can use either. - OTP emails are sent via the configured SMTP server. In dev, point SMTP at Mailpit or a console logger before testing - do not ship with the default "no-reply@example.com" sender.
Reference: Auth with OTP · JS SDK - authWithOTP
5. Implement Secure Password Authentication
Impact: CRITICAL (Secure user login with proper error handling and token management)
Password authentication should include proper error handling, avoid exposing whether emails exist, and correctly manage the auth store.
Incorrect (exposing information and poor error handling):
// Dangerous: exposes whether email exists
async function login(email, password) {
const user = await pb.collection('users').getFirstListItem(`email = "${email}"`);
if (!user) {
throw new Error('Email not found'); // Reveals email doesn't exist
}
// Manual password check - never do this!
if (user.password !== password) {
throw new Error('Wrong password'); // Reveals password is wrong
}
return user;
}Correct (secure authentication):
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
async function login(email, password) {
try {
// authWithPassword handles hashing and returns token
const authData = await pb.collection('users').authWithPassword(email, password);
// Token is automatically stored in pb.authStore
console.log('Logged in as:', authData.record.email);
console.log('Token valid:', pb.authStore.isValid);
return authData;
} catch (error) {
// Generic error message - don't reveal if email exists
if (error.status === 400) {
throw new Error('Invalid email or password');
}
throw error;
}
}
// Check if user is authenticated
function isAuthenticated() {
return pb.authStore.isValid;
}
// Get current user
function getCurrentUser() {
return pb.authStore.record;
}
// Logout
function logout() {
pb.authStore.clear();
}
// Listen for auth changes
pb.authStore.onChange((token, record) => {
console.log('Auth state changed:', record?.email || 'logged out');
}, true); // true = fire immediately with current stateAuth collection configuration for password auth:
// When creating auth collection via API (superuser only)
await pb.collections.create({
name: 'users',
type: 'auth',
fields: [
{ name: 'name', type: 'text' },
{ name: 'avatar', type: 'file', options: { maxSelect: 1 } }
],
passwordAuth: {
enabled: true,
identityFields: ['email', 'username'] // Fields that can be used to login
},
// Require minimum password length
// (configured in Admin UI under collection options)
});Security considerations:
- Never store passwords in plain text
- Use generic error messages
- Implement rate limiting on your server
- Consider adding MFA for sensitive applications
Reference: PocketBase Auth
6. Manage Auth Tokens Properly
Impact: CRITICAL (Prevents unauthorized access, handles token expiration gracefully)
Auth tokens should be refreshed before expiration, validated on critical operations, and properly cleared on logout. The SDK's authStore handles most of this automatically.
Incorrect (ignoring token expiration):
// Bad: never checking token validity
async function fetchUserData() {
// Token might be expired!
const records = await pb.collection('posts').getList();
return records;
}
// Bad: manually managing tokens
let authToken = localStorage.getItem('token');
fetch('/api/posts', {
headers: { 'Authorization': authToken } // Token might be invalid
});Correct (proper token management):
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// Check token validity before operations
async function fetchSecureData() {
// authStore.isValid is a client-side check only (JWT expiry parsing).
// Always verify server-side with authRefresh() for critical operations.
if (!pb.authStore.isValid) {
throw new Error('Please log in');
}
return pb.collection('posts').getList();
}
// Refresh token periodically or before expiration
async function refreshAuthIfNeeded() {
if (!pb.authStore.isValid) {
return false;
}
try {
// Verifies current token and returns fresh one
await pb.collection('users').authRefresh();
console.log('Token refreshed');
return true;
} catch (error) {
// Token invalid - user needs to re-authenticate
pb.authStore.clear();
return false;
}
}
// Auto-refresh on app initialization
async function initializeAuth() {
if (pb.authStore.token) {
try {
await pb.collection('users').authRefresh();
} catch {
pb.authStore.clear();
}
}
}
// Listen for auth changes and handle expiration
pb.authStore.onChange((token, record) => {
if (!token) {
// User logged out or token cleared
redirectToLogin();
}
});
// Setup periodic refresh (e.g., every 10 minutes)
setInterval(async () => {
if (pb.authStore.isValid) {
try {
await pb.collection('users').authRefresh();
} catch {
pb.authStore.clear();
}
}
}, 10 * 60 * 1000);SSR / Server-side token handling:
// Server-side: create fresh client per request
export async function handleRequest(request) {
const pb = new PocketBase('http://127.0.0.1:8090');
// Load auth from cookie
pb.authStore.loadFromCookie(request.headers.get('cookie') || '');
// Validate and refresh
if (pb.authStore.isValid) {
try {
await pb.collection('users').authRefresh();
} catch {
pb.authStore.clear();
}
}
// ... handle request ...
// Send updated cookie with secure options
const response = new Response();
response.headers.set('set-cookie', pb.authStore.exportToCookie({
httpOnly: true, // Prevent XSS access to auth token
secure: true, // HTTPS only
sameSite: 'Lax', // CSRF protection
}));
return response;
}Token configuration (Admin UI or migration):
// Configure token durations (superuser only)
await pb.collections.update('users', {
authToken: {
duration: 1209600 // 14 days in seconds
},
verificationToken: {
duration: 604800 // 7 days
}
});Reference: PocketBase Auth Store
Collection Design
Impact: CRITICAL
Schema design, field types, relations, indexes, and collection type selection. Foundation for application architecture and long-term maintainability.
---
1. Use Auth Collections for User Accounts
Impact: CRITICAL (Built-in authentication, password hashing, OAuth2 support)
Auth collections provide built-in authentication features including secure password hashing, email verification, OAuth2 support, and token management. Using base collections for users requires reimplementing these security-critical features.
Incorrect (using base collection for users):
// Base collection loses all auth features
const usersCollection = {
name: 'users',
type: 'base', // Wrong! No auth capabilities
schema: [
{ name: 'email', type: 'email' },
{ name: 'password', type: 'text' }, // Stored in plain text!
{ name: 'name', type: 'text' }
]
};
// Manual login implementation - insecure
const user = await pb.collection('users').getFirstListItem(
`email = "${email}" && password = "${password}"` // SQL injection risk!
);Correct (using auth collection):
// Auth collection with built-in security
const usersCollection = {
name: 'users',
type: 'auth', // Enables authentication features
schema: [
{ name: 'name', type: 'text' },
{ name: 'avatar', type: 'file', options: { maxSelect: 1 } }
],
options: {
allowEmailAuth: true,
allowOAuth2Auth: true,
requireEmail: true,
minPasswordLength: 8
}
};
// Secure authentication with password hashing
const authData = await pb.collection('users').authWithPassword(
'user@example.com',
'securePassword123'
);
// Token automatically stored in authStore
// NOTE: Never log tokens in production - shown here for illustration only
console.log('Authenticated as:', pb.authStore.record.id);When to use each type:
- Auth collection: User accounts, admin accounts, any entity that needs to log in
- Base collection: Regular data like posts, products, orders, comments
- View collection: Read-only aggregations or complex queries
Reference: PocketBase Auth Collections
2. Choose Appropriate Field Types for Your Data
Impact: CRITICAL (Prevents data corruption, improves query performance, reduces storage)
Selecting the wrong field type leads to data validation issues, wasted storage, and poor query performance. PocketBase provides specialized field types that enforce constraints at the database level.
Incorrect (using text for everything):
// Using plain text fields for structured data
const collection = {
name: 'products',
schema: [
{ name: 'price', type: 'text' }, // Should be number
{ name: 'email', type: 'text' }, // Should be email
{ name: 'website', type: 'text' }, // Should be url
{ name: 'active', type: 'text' }, // Should be bool
{ name: 'tags', type: 'text' }, // Should be select or json
{ name: 'created', type: 'text' } // Should be autodate
]
};
// No validation, inconsistent data, manual parsing requiredCorrect (using appropriate field types):
// Using specialized field types with proper validation
const collection = {
name: 'products',
type: 'base',
schema: [
{ name: 'price', type: 'number', options: { min: 0 } },
{ name: 'email', type: 'email' },
{ name: 'website', type: 'url' },
{ name: 'active', type: 'bool' },
{ name: 'tags', type: 'select', options: {
maxSelect: 5,
values: ['electronics', 'clothing', 'food', 'other']
}},
{ name: 'metadata', type: 'json' }
// created/updated are automatic system fields
]
};
// Built-in validation, proper indexing, type-safe queriesAvailable field types:
text- Plain text with optional min/max length, regex patternnumber- Integer or decimal with optional min/maxbool- True/false valuesemail- Email with format validationurl- URL with format validationdate- Date/datetime valuesautodate- Auto-set on create/updateselect- Single or multi-select from predefined valuesjson- Arbitrary JSON datafile- File attachmentsrelation- References to other collectionseditor- Rich text HTML content
Reference: PocketBase Collections
3. Use GeoPoint Fields for Location Data
Impact: MEDIUM (Built-in geographic queries, distance calculations)
PocketBase provides a dedicated GeoPoint field type for storing geographic coordinates with built-in distance query support via geoDistance().
Incorrect (storing coordinates as separate fields):
// Separate lat/lon fields - no built-in distance queries
const placesSchema = [
{ name: 'name', type: 'text' },
{ name: 'latitude', type: 'number' },
{ name: 'longitude', type: 'number' }
];
// Manual distance calculation - complex and slow
async function findNearby(lat, lon, maxKm) {
const places = await pb.collection('places').getFullList();
// Calculate distance for every record client-side
return places.filter(place => {
const dist = haversine(lat, lon, place.latitude, place.longitude);
return dist <= maxKm;
});
}Correct (using GeoPoint field):
// GeoPoint field stores coordinates as { lon, lat } object
const placesSchema = [
{ name: 'name', type: 'text' },
{ name: 'location', type: 'geopoint' }
];
// Creating a record with GeoPoint
await pb.collection('places').create({
name: 'Coffee Shop',
location: { lon: -73.9857, lat: 40.7484 } // Note: lon first!
});
// Or using "lon,lat" string format
await pb.collection('places').create({
name: 'Restaurant',
location: '-73.9857,40.7484' // String format also works
});
// Query nearby locations using geoDistance()
async function findNearby(lon, lat, maxKm) {
// geoDistance returns distance in kilometers
const places = await pb.collection('places').getList(1, 50, {
filter: pb.filter(
'geoDistance(location, {:point}) <= {:maxKm}',
{
point: { lon, lat },
maxKm: maxKm
}
),
sort: pb.filter('geoDistance(location, {:point})', { point: { lon, lat } })
});
return places;
}
// Find places within 5km of Times Square
const nearbyPlaces = await findNearby(-73.9857, 40.7580, 5);
// Use in API rules for location-based access
// listRule: geoDistance(location, @request.query.point) <= 10geoDistance() function:
// Syntax: geoDistance(geopointField, referencePoint)
// Returns: distance in kilometers
// In filter expressions
filter: 'geoDistance(location, "-73.9857,40.7484") <= 5'
// With parameter binding (recommended)
filter: pb.filter('geoDistance(location, {:center}) <= {:radius}', {
center: { lon: -73.9857, lat: 40.7484 },
radius: 5
})
// Sorting by distance
sort: 'geoDistance(location, "-73.9857,40.7484")' // Closest first
sort: '-geoDistance(location, "-73.9857,40.7484")' // Farthest firstGeoPoint data format:
// Object format (recommended)
{ lon: -73.9857, lat: 40.7484 }
// String format
"-73.9857,40.7484" // "lon,lat" order
// Important: longitude comes FIRST (GeoJSON convention)Use cases:
- Store-locator / find nearby
- Delivery radius validation
- Geofencing in API rules
- Location-based search results
Limitations:
- Spherical Earth calculation (accurate to ~0.3%)
- No polygon/area containment queries
- Single point per field (use multiple fields for routes)
Reference: PocketBase GeoPoint
4. Create Indexes for Frequently Filtered Fields
Impact: CRITICAL (10-100x faster queries on large collections)
PocketBase uses SQLite which benefits significantly from proper indexing. Queries filtering or sorting on unindexed fields perform full table scans.
Incorrect (no indexes on filtered fields):
// Querying without indexes
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'author = "user123" && status = "published"',
sort: '-publishedAt'
});
// Full table scan on large collections - very slow
// API rules also query without indexes
// listRule: "author = @request.auth.id"
// Every list request scans entire tableCorrect (indexed fields):
// Create collection with indexes via Admin UI or migration
// In PocketBase Admin: Collection > Indexes > Add Index
// Common index patterns:
// 1. Single field index for equality filters
// CREATE INDEX idx_posts_author ON posts(author)
// 2. Composite index for multiple filters
// CREATE INDEX idx_posts_author_status ON posts(author, status)
// 3. Index with sort field
// CREATE INDEX idx_posts_status_published ON posts(status, publishedAt DESC)
// Queries now use indexes
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'author = "user123" && status = "published"',
sort: '-publishedAt'
});
// Index scan - fast even with millions of records
// For unique constraints (e.g., slug)
// CREATE UNIQUE INDEX idx_posts_slug ON posts(slug)Index recommendations:
- Fields used in
filterexpressions - Fields used in
sortparameters - Fields used in API rules (
listRule,viewRule, etc.) - Relation fields (automatically indexed)
- Unique fields like slugs or codes
Index considerations for SQLite:
- Composite indexes work left-to-right (order matters)
- Too many indexes slow down writes
- Use
EXPLAIN QUERY PLANin SQL to verify index usage - Partial indexes for filtered subsets
-- Check if index is used
EXPLAIN QUERY PLAN
SELECT * FROM posts WHERE author = 'user123' AND status = 'published';
-- Should show "USING INDEX" not "SCAN"Reference: SQLite Query Planning
5. Configure Relations with Proper Cascade Options
Impact: CRITICAL (Maintains referential integrity, prevents orphaned records, controls deletion behavior)
Relation fields connect collections together. Proper cascade configuration ensures data integrity when referenced records are deleted.
Incorrect (default cascade behavior not considered):
// Relation without considering deletion behavior
const ordersSchema = [
{ name: 'customer', type: 'relation', options: {
collectionId: 'customers_collection_id',
maxSelect: 1
// No cascade options specified - defaults may cause issues
}},
{ name: 'products', type: 'relation', options: {
collectionId: 'products_collection_id'
// Multiple products, no cascade handling
}}
];
// Deleting a customer may fail or orphan orders
await pb.collection('customers').delete(customerId);
// Error: record is referenced by other recordsCorrect (explicit cascade configuration):
// Carefully configured relations
const ordersSchema = [
{
name: 'customer',
type: 'relation',
required: true,
options: {
collectionId: 'customers_collection_id',
maxSelect: 1,
cascadeDelete: false // Prevent accidental mass deletion
}
},
{
name: 'products',
type: 'relation',
options: {
collectionId: 'products_collection_id',
maxSelect: 99,
cascadeDelete: false
}
}
];
// For dependent data like comments - cascade delete makes sense
const commentsSchema = [
{
name: 'post',
type: 'relation',
options: {
collectionId: 'posts_collection_id',
maxSelect: 1,
cascadeDelete: true // Delete comments when post is deleted
}
}
];
// NOTE: For audit logs, avoid cascadeDelete - logs should be retained
// for compliance/forensics even after the referenced user is deleted.
// Use cascadeDelete: false and handle user deletion separately.
// Handle deletion manually when cascade is false
try {
await pb.collection('customers').delete(customerId);
} catch (e) {
if (e.status === 400) {
// Customer has orders - handle appropriately
// Option 1: Soft delete (set 'deleted' flag)
// Option 2: Reassign orders
// Option 3: Delete orders first
}
}Cascade options:
cascadeDelete: true- Delete referencing records when referenced record is deletedcascadeDelete: false- Block deletion if references exist (default for required relations)
Best practices:
- Use
cascadeDelete: truefor dependent data (comments on posts, logs for users) - Use
cascadeDelete: falsefor important data (orders, transactions) - Consider soft deletes for audit trails
- Document your cascade strategy
Reference: PocketBase Relations
6. Use View Collections for Complex Read-Only Queries
Impact: HIGH (Simplifies complex queries, improves maintainability, enables aggregations)
View collections execute custom SQL queries and expose results through the standard API. They're ideal for aggregations, joins, and computed fields without duplicating logic across your application.
Incorrect (computing aggregations client-side):
// Fetching all records to compute stats client-side
const orders = await pb.collection('orders').getFullList();
const products = await pb.collection('products').getFullList();
// Expensive client-side computation
const stats = orders.reduce((acc, order) => {
const product = products.find(p => p.id === order.product);
acc.totalRevenue += order.quantity * product.price;
acc.orderCount++;
return acc;
}, { totalRevenue: 0, orderCount: 0 });
// Fetches all data, slow, memory-intensiveCorrect (using view collection):
// Create a view collection in PocketBase Admin UI or via API
// View SQL:
// SELECT
// p.id,
// p.name,
// COUNT(o.id) as order_count,
// SUM(o.quantity) as total_sold,
// SUM(o.quantity * p.price) as revenue
// FROM products p
// LEFT JOIN orders o ON o.product = p.id
// GROUP BY p.id
// Simple, efficient query
const productStats = await pb.collection('product_stats').getList(1, 20, {
sort: '-revenue'
});
// Each record has computed fields
productStats.items.forEach(stat => {
console.log(`${stat.name}: ${stat.order_count} orders, $${stat.revenue}`);
});View collection use cases:
- Aggregations (COUNT, SUM, AVG)
- Joining data from multiple collections
- Computed/derived fields
- Denormalized read models
- Dashboard statistics
Limitations:
- Read-only (no create/update/delete)
- Must return
idcolumn - No realtime subscriptions
- API rules still apply for access control
Reference: PocketBase View Collections
File Handling
Impact: MEDIUM
File uploads, URL generation, thumbnail creation, and validation patterns.
---
1. Generate File URLs Correctly
Impact: MEDIUM (Proper URLs with thumbnails and access control)
Use the SDK's getURL method to generate proper file URLs with thumbnail support and access tokens for protected files.
Incorrect (manually constructing URLs):
// Hardcoded URL construction - brittle
const imageUrl = `http://localhost:8090/api/files/${record.collectionId}/${record.id}/${record.image}`;
// Missing token for protected files
const privateUrl = pb.files.getURL(record, record.document);
// Returns URL but file access denied if protected!
// Wrong thumbnail syntax
const thumb = `${imageUrl}?thumb=100x100`; // Wrong formatCorrect (using SDK methods):
// Basic file URL
const imageUrl = pb.files.getURL(record, record.image);
// Returns: http://host/api/files/COLLECTION/RECORD_ID/filename.jpg
// With thumbnail (for images only)
const thumbUrl = pb.files.getURL(record, record.image, {
thumb: '100x100' // Width x Height
});
// Thumbnail options
const thumbs = {
square: pb.files.getURL(record, record.image, { thumb: '100x100' }),
fit: pb.files.getURL(record, record.image, { thumb: '100x0' }), // Fit width
fitHeight: pb.files.getURL(record, record.image, { thumb: '0x100' }), // Fit height
crop: pb.files.getURL(record, record.image, { thumb: '100x100t' }), // Top crop
cropBottom: pb.files.getURL(record, record.image, { thumb: '100x100b' }), // Bottom
force: pb.files.getURL(record, record.image, { thumb: '100x100f' }), // Force exact
};
// Protected files (require auth)
async function getProtectedFileUrl(record, filename) {
// Get file access token (valid for limited time)
const token = await pb.files.getToken();
// Include token in URL
return pb.files.getURL(record, filename, { token });
}
// Example with protected document
async function downloadDocument(record) {
const token = await pb.files.getToken();
const url = pb.files.getURL(record, record.document, { token });
// Token is appended: ...?token=xxx
window.open(url, '_blank');
}React component example:
function UserAvatar({ user, size = 50 }) {
if (!user.avatar) {
return <DefaultAvatar size={size} />;
}
const avatarUrl = pb.files.getURL(user, user.avatar, {
thumb: `${size}x${size}`
});
return (
<img
src={avatarUrl}
alt={user.name}
width={size}
height={size}
loading="lazy"
/>
);
}
function ImageGallery({ record }) {
// Record has multiple images
const images = record.images || [];
return (
<div className="gallery">
{images.map((filename, index) => (
<img
key={filename}
src={pb.files.getURL(record, filename, { thumb: '200x200' })}
onClick={() => openFullSize(record, filename)}
loading="lazy"
/>
))}
</div>
);
}
function openFullSize(record, filename) {
const fullUrl = pb.files.getURL(record, filename);
window.open(fullUrl, '_blank');
}Handling file URLs in lists:
// Efficiently generate URLs for list of records
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author'
});
const postsWithUrls = posts.items.map(post => ({
...post,
thumbnailUrl: post.image
? pb.files.getURL(post, post.image, { thumb: '300x200' })
: null,
authorAvatarUrl: post.expand?.author?.avatar
? pb.files.getURL(post.expand.author, post.expand.author.avatar, { thumb: '40x40' })
: null
}));Thumbnail format reference:
| Format | Description |
|---|---|
WxH | Fit within dimensions |
Wx0 | Fit width, auto height |
0xH | Auto width, fit height |
WxHt | Crop from top |
WxHb | Crop from bottom |
WxHf | Force exact dimensions |
Performance and caching:
// File URLs are effectively immutable (randomized filenames on upload).
// This makes them ideal for aggressive caching.
// Configure Cache-Control via reverse proxy (Nginx/Caddy):
// location /api/files/ { add_header Cache-Control "public, immutable, max-age=86400"; }
// Thumbnails are generated on first request and cached by PocketBase.
// Pre-generate expected thumb sizes after upload to avoid cold-start latency:
async function uploadWithThumbs(record, file) {
const updated = await pb.collection('posts').update(record.id, { image: file });
// Pre-warm thumbnail cache by requesting expected sizes
const sizes = ['100x100', '300x200', '800x600'];
await Promise.all(sizes.map(size =>
fetch(pb.files.getURL(updated, updated.image, { thumb: size }))
));
return updated;
}S3 file serving optimization:
When using S3 storage, PocketBase proxies all file requests through the server. For better performance with public files, serve directly from your S3 CDN:
// Default: All file requests proxy through PocketBase
const url = pb.files.getURL(record, record.image);
// -> https://myapp.com/api/files/COLLECTION/ID/filename.jpg (proxied)
// For public files with S3 + CDN, construct CDN URL directly:
const cdnBase = 'https://cdn.myapp.com'; // Your S3 CDN domain
const cdnUrl = `${cdnBase}/${record.collectionId}/${record.id}/${record.image}`;
// Bypasses PocketBase, served directly from CDN edge
// NOTE: This only works for public files (no access token needed).
// Protected files must go through PocketBase for token validation.Reference: PocketBase Files
Note (JS SDK v0.26.7):pb.files.getURL()now serializes query parameters the same way as the fetch methods — passingnullorundefinedas a query param value is silently skipped from the generated URL, so you no longer need to guard optional params before passing them togetURL().
2. Upload Files Correctly
Impact: MEDIUM (Reliable uploads with progress tracking and validation)
File uploads can use plain objects or FormData. Handle large files properly with progress tracking and appropriate error handling.
Incorrect (naive file upload):
// Missing error handling
async function uploadFile(file) {
await pb.collection('documents').create({
title: file.name,
file: file
});
// No error handling, no progress feedback
}
// Uploading without validation
async function uploadAvatar(file) {
await pb.collection('users').update(userId, {
avatar: file // No size/type check - might fail server-side
});
}
// Base64 upload (inefficient)
async function uploadImage(base64) {
await pb.collection('images').create({
image: base64 // Wrong! PocketBase expects File/Blob
});
}Correct (proper file uploads):
// Basic upload with object (auto-converts to FormData)
async function uploadDocument(file, metadata) {
try {
const record = await pb.collection('documents').create({
title: metadata.title,
description: metadata.description,
file: file // File object from input
});
return record;
} catch (error) {
if (error.response?.data?.file) {
throw new Error(`File error: ${error.response.data.file.message}`);
}
throw error;
}
}
// Upload multiple files
async function uploadGallery(files, albumId) {
const record = await pb.collection('albums').update(albumId, {
images: files // Array of File objects
});
return record;
}
// FormData for more control
async function uploadWithProgress(file, onProgress) {
const formData = new FormData();
formData.append('title', file.name);
formData.append('file', file);
// Using fetch directly for progress (SDK doesn't expose progress)
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`Upload failed: ${xhr.status}`));
}
});
xhr.addEventListener('error', () => reject(new Error('Upload failed')));
xhr.open('POST', `${pb.baseURL}/api/collections/documents/records`);
xhr.setRequestHeader('Authorization', pb.authStore.token);
xhr.send(formData);
});
}
// Client-side validation before upload
function validateFile(file, options = {}) {
const {
maxSize = 10 * 1024 * 1024, // 10MB default
allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'],
maxNameLength = 100
} = options;
const errors = [];
if (file.size > maxSize) {
errors.push(`File too large. Max: ${maxSize / 1024 / 1024}MB`);
}
if (!allowedTypes.includes(file.type)) {
errors.push(`Invalid file type: ${file.type}`);
}
if (file.name.length > maxNameLength) {
errors.push(`Filename too long`);
}
return { valid: errors.length === 0, errors };
}
// Complete upload flow
async function handleFileUpload(inputEvent) {
const file = inputEvent.target.files[0];
if (!file) return;
// Validate
const validation = validateFile(file, {
maxSize: 5 * 1024 * 1024,
allowedTypes: ['image/jpeg', 'image/png']
});
if (!validation.valid) {
showError(validation.errors.join(', '));
return;
}
// Upload with progress
try {
setUploading(true);
const record = await uploadWithProgress(file, setProgress);
showSuccess('Upload complete!');
return record;
} catch (error) {
showError(error.message);
} finally {
setUploading(false);
}
}Deleting files:
// Remove specific file(s) from record
await pb.collection('albums').update(albumId, {
'images-': ['filename1.jpg', 'filename2.jpg'] // Remove these files
});
// Clear all files
await pb.collection('documents').update(docId, {
file: null // Removes the file
});Reference: PocketBase File Upload
3. Validate File Uploads
Impact: MEDIUM (Prevents invalid uploads, improves security and UX)
Validate files on both client and server side. Client validation improves UX; server validation (via collection settings) enforces security.
Incorrect (no validation):
// Accepting any file without checks
async function uploadFile(file) {
return pb.collection('uploads').create({ file });
// Could upload 1GB executable!
}
// Only checking extension (easily bypassed)
function validateFile(file) {
const ext = file.name.split('.').pop();
return ['jpg', 'png'].includes(ext);
// User can rename virus.exe to virus.jpg
}
// Client-only validation (can be bypassed)
async function uploadAvatar(file) {
if (file.size > 1024 * 1024) {
throw new Error('Too large');
}
// Attacker can bypass this with dev tools
return pb.collection('users').update(userId, { avatar: file });
}Correct (comprehensive validation):
// 1. Configure server-side validation in collection settings
// In Admin UI or via API:
const collectionConfig = {
schema: [
{
name: 'avatar',
type: 'file',
options: {
maxSelect: 1, // Single file only
maxSize: 5242880, // 5MB in bytes
mimeTypes: [ // Allowed types
'image/jpeg',
'image/png',
'image/gif',
'image/webp'
],
thumbs: ['100x100', '200x200'] // Auto-generate thumbnails
}
},
{
name: 'documents',
type: 'file',
options: {
maxSelect: 10,
maxSize: 10485760, // 10MB
mimeTypes: [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
]
}
}
]
};
// 2. Client-side validation for better UX
const FILE_CONSTRAINTS = {
avatar: {
maxSize: 5 * 1024 * 1024,
allowedTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
maxFiles: 1
},
documents: {
maxSize: 10 * 1024 * 1024,
allowedTypes: ['application/pdf'],
maxFiles: 10
}
};
function validateFiles(files, constraintKey) {
const constraints = FILE_CONSTRAINTS[constraintKey];
const errors = [];
const validFiles = [];
if (files.length > constraints.maxFiles) {
errors.push(`Maximum ${constraints.maxFiles} file(s) allowed`);
}
for (const file of files) {
const fileErrors = [];
// Check size
if (file.size > constraints.maxSize) {
const maxMB = constraints.maxSize / 1024 / 1024;
fileErrors.push(`${file.name}: exceeds ${maxMB}MB limit`);
}
// Check MIME type (more reliable than extension, but still spoofable)
// Client-side file.type is based on extension, not file content.
// Always enforce mimeTypes in PocketBase collection settings for server-side validation.
if (!constraints.allowedTypes.includes(file.type)) {
fileErrors.push(`${file.name}: invalid file type (${file.type || 'unknown'})`);
}
// Check for suspicious patterns
if (file.name.includes('..') || file.name.includes('/')) {
fileErrors.push(`${file.name}: invalid filename`);
}
if (fileErrors.length === 0) {
validFiles.push(file);
} else {
errors.push(...fileErrors);
}
}
return {
valid: errors.length === 0,
errors,
validFiles
};
}
// 3. Complete upload with validation
async function handleAvatarUpload(inputElement) {
const files = Array.from(inputElement.files);
// Client validation
const validation = validateFiles(files, 'avatar');
if (!validation.valid) {
showErrors(validation.errors);
return null;
}
// Upload (server will also validate)
try {
const updated = await pb.collection('users').update(userId, {
avatar: validation.validFiles[0]
});
showSuccess('Avatar updated!');
return updated;
} catch (error) {
// Handle server validation errors
if (error.response?.data?.avatar) {
showError(error.response.data.avatar.message);
} else {
showError('Upload failed');
}
return null;
}
}
// 4. Image-specific validation
async function validateImage(file, options = {}) {
const { minWidth = 0, minHeight = 0, maxWidth = Infinity, maxHeight = Infinity } = options;
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const errors = [];
if (img.width < minWidth || img.height < minHeight) {
errors.push(`Image must be at least ${minWidth}x${minHeight}px`);
}
if (img.width > maxWidth || img.height > maxHeight) {
errors.push(`Image must be at most ${maxWidth}x${maxHeight}px`);
}
resolve({ valid: errors.length === 0, errors, width: img.width, height: img.height });
};
img.onerror = () => resolve({ valid: false, errors: ['Invalid image file'] });
img.src = URL.createObjectURL(file);
});
}Reference: PocketBase Files Configuration
Production & Deployment
Impact: LOW-MEDIUM
Backup strategies, configuration management, reverse proxy setup, and SQLite optimization.
---
1. Implement Proper Backup Strategies
Impact: LOW-MEDIUM (Prevents data loss, enables disaster recovery)
Regular backups are essential for production deployments. PocketBase provides built-in backup functionality and supports external S3 storage.
Incorrect (no backup strategy):
// No backups at all - disaster waiting to happen
// Just running: ./pocketbase serve
// Manual file copy while server running - can corrupt data
// cp pb_data/data.db backup/
// Only backing up database, missing files
// sqlite3 pb_data/data.db ".backup backup.db"Correct (comprehensive backup strategy):
// 1. Using PocketBase Admin API for backups
const adminPb = new PocketBase('http://127.0.0.1:8090');
await adminPb.collection('_superusers').authWithPassword(admin, password);
// Create backup (includes database and files)
async function createBackup(name = '') {
const backup = await adminPb.backups.create(name);
console.log('Backup created:', backup.key);
return backup;
}
// List available backups
async function listBackups() {
const backups = await adminPb.backups.getFullList();
backups.forEach(b => {
console.log(`${b.key} - ${b.size} bytes - ${b.modified}`);
});
return backups;
}
// Download backup
async function downloadBackup(key) {
const token = await adminPb.files.getToken();
const url = adminPb.backups.getDownloadURL(token, key);
// url can be used to download the backup file
return url;
}
// Restore from backup (CAUTION: overwrites current data!)
async function restoreBackup(key) {
await adminPb.backups.restore(key);
console.log('Restore initiated - server will restart');
}
// Delete old backups
async function cleanupOldBackups(keepCount = 7) {
const backups = await adminPb.backups.getFullList();
// Sort by date, keep newest
const sorted = backups.sort((a, b) =>
new Date(b.modified) - new Date(a.modified)
);
const toDelete = sorted.slice(keepCount);
for (const backup of toDelete) {
await adminPb.backups.delete(backup.key);
console.log('Deleted old backup:', backup.key);
}
}Automated backup script (cron job):
#!/bin/bash
# backup.sh - Run daily via cron
POCKETBASE_URL="http://127.0.0.1:8090"
ADMIN_EMAIL="admin@example.com"
ADMIN_PASSWORD="your-secure-password"
BACKUP_DIR="/path/to/backups"
KEEP_DAYS=7
# Create timestamp
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# Create backup via API
curl -X POST "${POCKETBASE_URL}/api/backups" \
-H "Authorization: $(curl -s -X POST "${POCKETBASE_URL}/api/collections/_superusers/auth-with-password" \
-d "identity=${ADMIN_EMAIL}&password=${ADMIN_PASSWORD}" | jq -r '.token')" \
-d "name=backup_${TIMESTAMP}"
# Clean old local backups
find "${BACKUP_DIR}" -name "*.zip" -mtime +${KEEP_DAYS} -delete
echo "Backup completed: backup_${TIMESTAMP}"Configure S3 for backup storage:
// In Admin UI: Settings > Backups > S3
// Or via API:
await adminPb.settings.update({
backups: {
s3: {
enabled: true,
bucket: 'my-pocketbase-backups',
region: 'us-east-1',
endpoint: 's3.amazonaws.com',
accessKey: process.env.AWS_ACCESS_KEY,
secret: process.env.AWS_SECRET_KEY
}
}
});Backup best practices:
| Aspect | Recommendation |
|---|---|
| Frequency | Daily minimum, hourly for critical apps |
| Retention | 7-30 days of daily backups |
| Storage | Off-site (S3, separate server) |
| Testing | Monthly restore tests |
| Monitoring | Alert on backup failures |
Pre-backup checklist:
- [ ] S3 or external storage configured
- [ ] Automated schedule set up
- [ ] Retention policy defined
- [ ] Restore procedure documented
- [ ] Restore tested successfully
Reference: PocketBase Backups
2. Configure Production Settings Properly
Impact: LOW-MEDIUM (Secure and optimized production environment)
Production deployments require proper configuration of URLs, secrets, SMTP, and security settings.
Incorrect (development defaults in production):
# Running with defaults - insecure!
./pocketbase serve
# Hardcoded secrets
./pocketbase serve --encryptionEnv="mySecretKey123"
# Wrong origin for CORS
# Leaving http://localhost:8090 as allowed originCorrect (production configuration):
# Production startup with essential flags
./pocketbase serve \
--http="0.0.0.0:8090" \
--origins="https://myapp.com,https://www.myapp.com" \
--encryptionEnv="PB_ENCRYPTION_KEY"
# Using environment variables
export PB_ENCRYPTION_KEY="your-32-char-encryption-key-here"
export SMTP_HOST="smtp.sendgrid.net"
export SMTP_PORT="587"
export SMTP_USER="apikey"
export SMTP_PASS="your-sendgrid-api-key"
./pocketbase serve --http="0.0.0.0:8090"Configure SMTP for emails:
// Via Admin UI or API
await adminPb.settings.update({
smtp: {
enabled: true,
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT),
username: process.env.SMTP_USER,
password: process.env.SMTP_PASS,
tls: true
},
meta: {
appName: 'My App',
appURL: 'https://myapp.com',
senderName: 'My App',
senderAddress: 'noreply@myapp.com'
}
});
// Test email configuration
await adminPb.settings.testEmail('users', 'test@example.com', 'verification');Configure S3 for file storage:
// Move file storage to S3 for scalability
await adminPb.settings.update({
s3: {
enabled: true,
bucket: 'my-app-files',
region: 'us-east-1',
endpoint: 's3.amazonaws.com',
accessKey: process.env.AWS_ACCESS_KEY,
secret: process.env.AWS_SECRET_KEY,
forcePathStyle: false
}
});
// Test S3 connection
await adminPb.settings.testS3('storage');Systemd service file:
# /etc/systemd/system/pocketbase.service
[Unit]
Description=PocketBase
After=network.target
[Service]
Type=simple
User=pocketbase
Group=pocketbase
LimitNOFILE=4096
Restart=always
RestartSec=5s
WorkingDirectory=/opt/pocketbase
ExecStart=/opt/pocketbase/pocketbase serve --http="127.0.0.1:8090"
# Environment variables
EnvironmentFile=/opt/pocketbase/.env
# Security hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/pocketbase/pb_data
[Install]
WantedBy=multi-user.targetEnvironment file (.env):
# /opt/pocketbase/.env
# SECURITY: Set restrictive permissions: chmod 600 /opt/pocketbase/.env
# SECURITY: Add to .gitignore - NEVER commit this file to version control
# For production, consider a secrets manager (Vault, AWS Secrets Manager, etc.)
PB_ENCRYPTION_KEY= # Generate with: openssl rand -hex 16
# SMTP
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS= # Set your SMTP password here
# S3 (optional)
AWS_ACCESS_KEY= # Set your AWS access key
AWS_SECRET_KEY= # Set your AWS secret key
# OAuth (optional)
GOOGLE_CLIENT_ID= # Set your Google client ID
GOOGLE_CLIENT_SECRET= # Set your Google client secretProtect your environment file:
# Set restrictive permissions (owner read/write only)
chmod 600 /opt/pocketbase/.env
chown pocketbase:pocketbase /opt/pocketbase/.env
# Ensure .env is in .gitignore
echo ".env" >> .gitignoreProduction checklist:
- [ ] HTTPS enabled (via reverse proxy)
- [ ] Strong encryption key set
- [ ] CORS origins configured
- [ ] SMTP configured and tested
- [ ] Superuser password changed
- [ ] S3 configured (for scalability)
- [ ] Backup schedule configured
- [ ] Rate limiting enabled (via reverse proxy)
- [ ] Logging configured
Reference: PocketBase Going to Production
3. Enable Rate Limiting for API Protection
Impact: MEDIUM (Prevents abuse, brute-force attacks, and DoS)
PocketBase v0.23+ includes built-in rate limiting. Enable it to protect against brute-force attacks, API abuse, and excessive resource consumption.
v0.36.7 behavioral change: the built-in limiter switched from a sliding-window to a fixed-window strategy. This is cheaper and more predictable, but it means a client can send 2 * maxRequests in quick succession if they straddle the window boundary. Size your limits with that worst case in mind, and put Nginx/Caddy rate limiting in front of PocketBase for defense in depth (see examples below).Incorrect (no rate limiting):
# Running without rate limiting
./pocketbase serve
# Vulnerable to:
# - Brute-force password attacks
# - API abuse and scraping
# - DoS from excessive requests
# - Account enumeration attemptsCorrect (enable rate limiting):
# Enable via command line flag
./pocketbase serve --rateLimiter=true
# Or configure specific limits (requests per second per IP)
./pocketbase serve --rateLimiter=true --rateLimiterRPS=10Configure via Admin Dashboard:
Navigate to Settings > Rate Limiter:
- Enable rate limiter: Toggle on
- Max requests/second: Default 10, adjust based on needs
- Exempt endpoints: Optionally whitelist certain paths
Configure programmatically (Go/JS hooks):
// In pb_hooks/rate_limit.pb.js
routerAdd("GET", "/api/public/*", (e) => {
// Custom rate limit for specific endpoints
}, $apis.rateLimit(100, "10s")); // 100 requests per 10 seconds
// Stricter limit for auth endpoints
routerAdd("POST", "/api/collections/users/auth-*", (e) => {
// Auth endpoints need stricter limits
}, $apis.rateLimit(5, "1m")); // 5 attempts per minuteRate limiting with reverse proxy (additional layer):
# Nginx rate limiting (defense in depth)
http {
# Define rate limit zones
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=1r/s;
server {
# General API rate limit
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://pocketbase;
}
# Strict limit for auth endpoints
location /api/collections/users/auth {
limit_req zone=auth burst=5 nodelay;
proxy_pass http://pocketbase;
}
# Stricter limit for superuser auth
location /api/collections/_superusers/auth {
limit_req zone=auth burst=3 nodelay;
proxy_pass http://pocketbase;
}
}
}# Caddy with rate limiting plugin
myapp.com {
rate_limit {
zone api {
key {remote_host}
events 100
window 10s
}
zone auth {
key {remote_host}
events 5
window 1m
}
}
@auth path /api/collections/*/auth*
handle @auth {
rate_limit { zone auth }
reverse_proxy 127.0.0.1:8090
}
handle {
rate_limit { zone api }
reverse_proxy 127.0.0.1:8090
}
}Handle rate limit errors in client:
async function makeRequest(fn, retries = 0, maxRetries = 3) {
try {
return await fn();
} catch (error) {
if (error.status === 429 && retries < maxRetries) {
// Rate limited - wait and retry with limit
const retryAfter = error.response?.retryAfter || 60;
console.log(`Rate limited. Retry ${retries + 1}/${maxRetries} after ${retryAfter}s`);
// Show user-friendly message
showMessage('Too many requests. Please wait a moment.');
await sleep(retryAfter * 1000);
return makeRequest(fn, retries + 1, maxRetries);
}
throw error;
}
}
// Usage
const result = await makeRequest(() =>
pb.collection('posts').getList(1, 20)
);Recommended limits by endpoint type:
| Endpoint Type | Suggested Limit | Reason |
|---|---|---|
| Auth endpoints | 5-10/min | Prevent brute-force |
| Password reset | 3/hour | Prevent enumeration |
| Record creation | 30/min | Prevent spam |
| General API | 60-100/min | Normal usage |
| Public read | 100-200/min | Higher for reads |
| File uploads | 10/min | Resource-intensive |
Monitoring rate limit hits:
// Check PocketBase logs for rate limit events
// Or set up alerting in your monitoring system
// Client-side tracking
pb.afterSend = function(response, data) {
if (response.status === 429) {
trackEvent('rate_limit_hit', {
endpoint: response.url,
timestamp: new Date()
});
}
return data;
};Reference: PocketBase Going to Production
4. Configure Reverse Proxy Correctly
Impact: LOW-MEDIUM (HTTPS, caching, rate limiting, and security headers)
Use a reverse proxy (Nginx, Caddy) for HTTPS termination, caching, rate limiting, and security headers.
Incorrect (exposing PocketBase directly):
# Direct exposure - no HTTPS, no rate limiting
./pocketbase serve --http="0.0.0.0:8090"
# Port forwarding without proxy
iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 8090
# Still no HTTPS!Correct (Caddy - simplest option):
# /etc/caddy/Caddyfile
myapp.com {
# Automatic HTTPS via Let's Encrypt
reverse_proxy 127.0.0.1:8090 {
# Required for SSE/Realtime
flush_interval -1
}
# Security headers
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# Restrict admin UI to internal/VPN networks
# @admin path /_/*
# handle @admin {
# @blocked not remote_ip 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
# respond @blocked 403
# reverse_proxy 127.0.0.1:8090
# }
# Rate limiting (requires caddy-ratelimit plugin)
# Install: xcaddy build --with github.com/mholt/caddy-ratelimit
# Without this plugin, use PocketBase's built-in rate limiter (--rateLimiter=true)
# rate_limit {
# zone api {
# key {remote_host}
# events 100
# window 1m
# }
# }
}Correct (Nginx configuration):
# /etc/nginx/sites-available/pocketbase
# Rate limit zones must be defined in http context (e.g., /etc/nginx/nginx.conf)
# limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
upstream pocketbase {
server 127.0.0.1:8090;
keepalive 64;
}
server {
listen 80;
server_name myapp.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name myapp.com;
# SSL configuration
ssl_certificate /etc/letsencrypt/live/myapp.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/myapp.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Note: X-XSS-Protection is deprecated and can introduce vulnerabilities.
# Use Content-Security-Policy instead.
location / {
proxy_pass http://pocketbase;
proxy_http_version 1.1;
# Headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE/Realtime support
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
# Timeouts
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Rate limit API endpoints
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://pocketbase;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection '';
proxy_buffering off;
}
# Static file caching
location /api/files/ {
proxy_pass http://pocketbase;
proxy_cache_valid 200 1d;
expires 1d;
add_header Cache-Control "public, immutable";
}
# Gzip compression
gzip on;
gzip_types text/plain application/json application/javascript text/css;
gzip_min_length 1000;
}Docker Compose with Caddy:
# docker-compose.yml
version: '3.8'
services:
pocketbase:
# NOTE: This is a third-party community image, not officially maintained by PocketBase.
# For production, consider building your own image from the official PocketBase binary.
# See: https://pocketbase.io/docs/going-to-production/
image: ghcr.io/muchobien/pocketbase:latest
restart: unless-stopped
volumes:
- ./pb_data:/pb_data
environment:
- PB_ENCRYPTION_KEY=${PB_ENCRYPTION_KEY}
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- pocketbase
volumes:
caddy_data:
caddy_config:Key configuration points:
| Feature | Why It Matters |
|---|---|
| HTTPS | Encrypts traffic, required for auth |
| SSE support | proxy_buffering off for realtime |
| Rate limiting | Prevents abuse |
| Security headers | XSS/clickjacking protection |
| Keepalive | Connection reuse, better performance |
Reference: PocketBase Going to Production
5. Tune OS and Runtime for PocketBase Scale
Impact: MEDIUM (Prevents file descriptor exhaustion, OOM kills, and exposes secure config for production deployments)
Three low-effort OS/runtime knobs have outsized impact on production stability: open-file limits for realtime connections, Go memory limits for constrained hosts, and settings encryption for shared or externally-backed infrastructure. None of these are set automatically.
Incorrect (default OS limits, no memory governor, plain-text settings):
# Start without raising the file descriptor limit
/root/pb/pocketbase serve yourdomain.com
# → "Too many open files" once concurrent realtime connections exceed ~1024
# Start in a container that has a 512 MB RAM cap without GOMEMLIMIT
docker run -m 512m pocketbase serve ...
# → OOM kill during large file upload because Go GC doesn't respect cgroup limits
# Store SMTP password and S3 secret as plain JSON in pb_data/data.db
pocketbase serve # no --encryptionEnv
# → Anyone who obtains the database backup can read all credentialsCorrect:
# 1. Raise the open-file limit before starting (Linux/macOS)
# Check current limit first:
ulimit -a | grep "open files"
# Temporarily raise to 4096 for the current session:
ulimit -n 4096
/root/pb/pocketbase serve yourdomain.com
# Or persist it via systemd (recommended for production):
# /lib/systemd/system/pocketbase.service
# [Service]
# LimitNOFILE = 4096
# ...
# 2. Cap Go's soft memory target on memory-constrained hosts
# (instructs the GC to be more aggressive before the kernel OOM-kills the process)
GOMEMLIMIT=512MiB /root/pb/pocketbase serve yourdomain.com
# 3. Encrypt application settings at rest
# Generate a random 32-character key once:
export PB_ENCRYPTION_KEY="z76NX9WWiB05UmQGxw367B6zM39T11fF"
# Start with the env-var name (not the value) as the flag argument:
pocketbase serve --encryptionEnv=PB_ENCRYPTION_KEYDocker deployment pattern (v0.36.8):
FROM alpine:latest
ARG PB_VERSION=0.36.8
RUN apk add --no-cache unzip ca-certificates
ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip /tmp/pb.zip
RUN unzip /tmp/pb.zip -d /pb/
# Uncomment to bundle pre-written migrations or hooks:
# COPY ./pb_migrations /pb/pb_migrations
# COPY ./pb_hooks /pb/pb_hooks
EXPOSE 8080
# Mount a volume at /pb/pb_data to persist data across container restarts
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8080"]# docker-compose.yml
services:
pocketbase:
build: .
ports:
- "8080:8080"
volumes:
- pb_data:/pb/pb_data
environment:
GOMEMLIMIT: "512MiB"
PB_ENCRYPTION_KEY: "${PB_ENCRYPTION_KEY}"
command: ["/pb/pocketbase", "serve", "--http=0.0.0.0:8080", "--encryptionEnv=PB_ENCRYPTION_KEY"]
volumes:
pb_data:Quick-reference checklist:
| Concern | Fix |
|---|---|
Too many open files errors | ulimit -n 4096 (or LimitNOFILE=4096 in systemd) |
| OOM kill on constrained host | GOMEMLIMIT=512MiB env var |
| Credentials visible in DB backup | --encryptionEnv=YOUR_VAR with a 32-char random key |
| Persistent data in Docker | Mount volume at /pb/pb_data |
Reference: Going to production
6. Optimize SQLite for Production
Impact: LOW-MEDIUM (Better performance and reliability for SQLite database)
PocketBase uses SQLite with optimized defaults. Understanding its characteristics helps optimize performance and avoid common pitfalls. PocketBase uses two separate databases: data.db (application data) and auxiliary.db (logs and ephemeral data), which reduces write contention.
Incorrect (ignoring SQLite characteristics):
// Heavy concurrent writes - SQLite bottleneck
async function bulkInsert(items) {
// Parallel writes cause lock contention
await Promise.all(items.map(item =>
pb.collection('items').create(item)
));
}
// Not using transactions for batch operations
async function updateMany(items) {
for (const item of items) {
await pb.collection('items').update(item.id, item);
}
// Each write is a separate transaction - slow!
}
// Large text fields without consideration
const schema = [{
name: 'content',
type: 'text' // Could be megabytes - affects all queries
}];Correct (SQLite-optimized patterns):
// Use batch operations for multiple writes
async function bulkInsert(items) {
const batch = pb.createBatch();
items.forEach(item => {
batch.collection('items').create(item);
});
await batch.send(); // Single transaction, much faster
}
// Batch updates
async function updateMany(items) {
const batch = pb.createBatch();
items.forEach(item => {
batch.collection('items').update(item.id, item);
});
await batch.send();
}
// For very large batches, chunk them
async function bulkInsertLarge(items, chunkSize = 100) {
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
const batch = pb.createBatch();
chunk.forEach(item => batch.collection('items').create(item));
await batch.send();
}
}Schema considerations:
// Separate large content into dedicated collection
const postsSchema = [
{ name: 'title', type: 'text' },
{ name: 'summary', type: 'text', options: { maxLength: 500 } },
{ name: 'author', type: 'relation' }
// Content in separate collection
];
const postContentsSchema = [
{ name: 'post', type: 'relation', required: true },
{ name: 'content', type: 'editor' } // Large HTML content
];
// Fetch content only when needed
async function getPostList() {
return pb.collection('posts').getList(1, 20); // Fast, no content
}
async function getPostWithContent(id) {
const post = await pb.collection('posts').getOne(id);
const content = await pb.collection('post_contents').getFirstListItem(
pb.filter('post = {:id}', { id })
);
return { ...post, content: content.content };
}PocketBase default PRAGMA settings:
PocketBase already configures optimal SQLite settings. You do not need to set these manually unless using a custom SQLite driver:
PRAGMA busy_timeout = 10000; -- Wait 10s for locks instead of failing immediately
PRAGMA journal_mode = WAL; -- Write-Ahead Logging: concurrent reads during writes
PRAGMA journal_size_limit = 200000000; -- Limit WAL file to ~200MB
PRAGMA synchronous = NORMAL; -- Balanced durability/performance (safe with WAL)
PRAGMA foreign_keys = ON; -- Enforce relation integrity
PRAGMA temp_store = MEMORY; -- Temp tables in memory (faster sorts/joins)
PRAGMA cache_size = -32000; -- 32MB page cacheWAL mode is the most impactful setting -- it allows multiple concurrent readers while a single writer is active, which is critical for PocketBase's concurrent API request handling.
Index optimization:
-- Create indexes for commonly filtered/sorted fields
CREATE INDEX idx_posts_author ON posts(author);
CREATE INDEX idx_posts_created ON posts(created DESC);
CREATE INDEX idx_posts_status_created ON posts(status, created DESC);
-- Verify indexes are being used
EXPLAIN QUERY PLAN
SELECT * FROM posts WHERE author = 'xxx' ORDER BY created DESC;
-- Should show: "USING INDEX idx_posts_author"SQLite limitations and workarounds:
| Limitation | Workaround |
|---|---|
| Single writer | Use batch operations, queue writes |
| No full-text by default | Use view collections with FTS5 |
| File-based | SSD storage, avoid network mounts |
| Memory for large queries | Pagination, limit result sizes |
Performance monitoring:
// Monitor slow queries via hooks (requires custom PocketBase build)
// Or use SQLite's built-in profiling
// From sqlite3 CLI:
// .timer on
// SELECT * FROM posts WHERE author = 'xxx';
// Run Time: real 0.003 user 0.002 sys 0.001
// Check database size
// ls -lh pb_data/data.db
// Vacuum to reclaim space after deletes
// sqlite3 pb_data/data.db "VACUUM;"When to consider alternatives:
Consider migrating from single PocketBase if:
- Write throughput consistently > 1000/sec needed
- Database size > 100GB
- Complex transactions across tables
- Multi-region deployment required
Custom SQLite driver (advanced):
PocketBase supports custom SQLite drivers via DBConnect. The CGO driver (mattn/go-sqlite3) can offer better performance for some workloads and enables extensions like ICU and FTS5. This requires a custom PocketBase build:
// main.go (custom PocketBase build with CGO driver)
package main
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
_ "github.com/mattn/go-sqlite3" // CGO SQLite driver
)
func main() {
app := pocketbase.NewWithConfig(pocketbase.Config{
// Called twice: once for data.db, once for auxiliary.db
DBConnect: func(dbPath string) (*dbx.DB, error) {
return dbx.Open("sqlite3", dbPath)
},
})
if err := app.Start(); err != nil {
panic(err)
}
}
// Build with: CGO_ENABLED=1 go buildNote: CGO requires C compiler toolchain and cannot be cross-compiled as easily as pure Go.
Scaling options: 1. Read replicas: Litestream for SQLite replication 2. Sharding: Multiple PocketBase instances by tenant/feature 3. Caching: Redis/Memcached for read-heavy loads 4. Alternative backend: If requirements exceed SQLite, evaluate PostgreSQL-based frameworks
Reference: PocketBase Going to Production
Section Definitions
This file defines the rule categories for PocketBase best practices. Rules are automatically assigned to sections based on their filename prefix.
---
1. Collection Design (coll)
Impact: CRITICAL Description: Schema design, field types, relations, indexes, and collection type selection. Foundation for application architecture and long-term maintainability.
2. API Rules & Security (rules)
Impact: CRITICAL Description: Access control rules, filter expressions, request context usage, and security patterns. Critical for protecting data and enforcing authorization.
3. Authentication (auth)
Impact: CRITICAL Description: Password authentication, OAuth2 integration, token management, MFA setup, and auth collection configuration.
4. SDK Usage (sdk)
Impact: HIGH Description: JavaScript SDK initialization, auth store patterns, error handling, request cancellation, and safe parameter binding.
5. Query Performance (query)
Impact: HIGH Description: Pagination strategies, relation expansion, field selection, batch operations, and N+1 query prevention.
6. Realtime (realtime)
Impact: MEDIUM Description: SSE subscriptions, event handling, connection management, and authentication with realtime.
7. File Handling (file)
Impact: MEDIUM Description: File uploads, URL generation, thumbnail creation, and validation patterns.
8. Production & Deployment (deploy)
Impact: LOW-MEDIUM Description: Backup strategies, configuration management, reverse proxy setup, and SQLite optimization.
9. Server-Side Extending (ext)
Impact: HIGH Description: Extending PocketBase with Go or embedded JavaScript (JSVM) - event hooks, custom routes, transactions, cron jobs, filesystem, migrations, and safe server-side filter binding.
[Rule Title]
[1-2 sentence explanation of the problem and why it matters. Focus on impact.]
Incorrect (describe the problem):
// Comment explaining what makes this problematic
const result = await pb.collection('posts').getList();
// Problem explanationCorrect (describe the solution):
// Comment explaining why this is better
const result = await pb.collection('posts').getList(1, 20, {
filter: 'published = true',
sort: '-created'
});
// Benefit explanation[Optional: Additional context, edge cases, or trade-offs]
Reference: PocketBase Docs
Use Impersonation for Admin Operations
Impersonation allows superusers to generate tokens for other users, enabling admin support tasks and API key functionality without sharing passwords.
Incorrect (sharing credentials or bypassing auth):
// Bad: sharing user passwords for support
async function helpUser(userId, userPassword) {
await pb.collection('users').authWithPassword(userEmail, userPassword);
// Support team knows user's password!
}
// Bad: directly modifying records without proper context
async function fixUserData(userId) {
// Bypasses user's perspective and rules
await pb.collection('posts').update(postId, { fixed: true });
}Correct (using impersonation):
import PocketBase from 'pocketbase';
// Admin client with superuser auth (use environment variables, never hardcode)
const adminPb = new PocketBase(process.env.PB_URL);
await adminPb.collection('_superusers').authWithPassword(
process.env.PB_SUPERUSER_EMAIL,
process.env.PB_SUPERUSER_PASSWORD
);
async function impersonateUser(userId) {
// Generate impersonation token (non-renewable)
const impersonatedClient = await adminPb
.collection('users')
.impersonate(userId, 3600); // 1 hour duration
// impersonatedClient has user's auth context
console.log('Acting as:', impersonatedClient.authStore.record.email);
// Operations use user's permissions
const userPosts = await impersonatedClient.collection('posts').getList();
return impersonatedClient;
}
// Use case: Admin viewing user's data
async function adminViewUserPosts(userId) {
const userClient = await impersonateUser(userId);
// See exactly what the user sees (respects API rules)
const posts = await userClient.collection('posts').getList();
return posts;
}
// Use case: API keys for server-to-server communication
async function createApiKey(serviceUserId) {
// Create a service impersonation token (use short durations, rotate regularly)
const serviceClient = await adminPb
.collection('service_accounts')
.impersonate(serviceUserId, 86400); // 24 hours max, rotate via scheduled task
// Return token for service to use
return serviceClient.authStore.token;
}
// Using API key token in another service
async function useApiKey(apiToken) {
const pb = new PocketBase('http://127.0.0.1:8090');
// Manually set the token
pb.authStore.save(apiToken, null);
// Now requests use the service account's permissions
const data = await pb.collection('data').getList();
return data;
}Important considerations:
// Impersonation tokens are non-renewable
const client = await adminPb.collection('users').impersonate(userId, 3600);
// This will fail - can't refresh impersonation tokens
try {
await client.collection('users').authRefresh();
} catch (error) {
// Expected: impersonation tokens can't be refreshed
}
// For continuous access, generate new token when needed
async function getImpersonatedClient(userId) {
// Check if existing token is still valid
if (cachedClient?.authStore.isValid) {
return cachedClient;
}
// Generate fresh token
return await adminPb.collection('users').impersonate(userId, 3600);
}Security best practices:
- Use short durations for support tasks
- Log all impersonation events
- Restrict impersonation to specific admin roles
- Never expose impersonation capability in client code
- Use dedicated service accounts for API keys
Reference: PocketBase Impersonation
Implement Multi-Factor Authentication
MFA requires users to authenticate with two different methods. PocketBase supports OTP (One-Time Password) via email as the second factor.
Incorrect (single-factor only for sensitive apps):
// Insufficient for sensitive applications
async function login(email, password) {
const authData = await pb.collection('users').authWithPassword(email, password);
// User immediately has full access - no second factor
return authData;
}Correct (MFA flow with OTP):
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
async function loginWithMFA(email, password) {
try {
// First factor: password
const authData = await pb.collection('users').authWithPassword(email, password);
// If MFA not required, auth succeeds immediately
return { success: true, authData };
} catch (error) {
// MFA required - returns 401 with mfaId
if (error.status === 401 && error.response?.mfaId) {
return {
success: false,
mfaRequired: true,
mfaId: error.response.mfaId
};
}
throw error;
}
}
async function requestOTP(email) {
// Request OTP to be sent via email
const result = await pb.collection('users').requestOTP(email);
// Returns otpId - needed to verify the OTP
// Note: Returns otpId even if email doesn't exist (prevents enumeration)
return result.otpId;
}
async function completeMFAWithOTP(mfaId, otpId, otpCode) {
try {
// Second factor: OTP verification
const authData = await pb.collection('users').authWithOTP(
otpId,
otpCode,
{ mfaId } // Include mfaId from first factor
);
return { success: true, authData };
} catch (error) {
if (error.status === 400) {
throw new Error('Invalid or expired code');
}
throw error;
}
}
// Complete flow example
async function fullMFAFlow(email, password, otpCode = null) {
// Step 1: Password authentication
const step1 = await loginWithMFA(email, password);
if (step1.success) {
return step1.authData; // MFA not required
}
if (step1.mfaRequired) {
// Step 2: Request OTP
const otpId = await requestOTP(email);
// Step 3: UI prompts user for OTP code...
// (In real app, wait for user input)
if (otpCode) {
// Step 4: Complete MFA
const step2 = await completeMFAWithOTP(step1.mfaId, otpId, otpCode);
return step2.authData;
}
return { pendingMFA: true, mfaId: step1.mfaId, otpId };
}
}Configure MFA (Admin UI or API):
// Enable MFA on auth collection (superuser only)
await pb.collections.update('users', {
mfa: {
enabled: true,
duration: 1800, // MFA session duration (30 min)
rule: '' // When to require MFA (empty = always for all users)
// rule: '@request.auth.role = "admin"' // Only for admins
},
otp: {
enabled: true,
duration: 300, // OTP validity (5 min)
length: 6, // OTP code length
emailTemplate: {
subject: 'Your verification code',
body: 'Your code is: {OTP}'
}
}
});MFA best practices:
- Always enable for admin accounts
- Consider making MFA optional for regular users
- Use short OTP durations (5-10 minutes)
- Implement rate limiting on OTP requests
- Log MFA events for security auditing
Reference: PocketBase MFA
Integrate OAuth2 Providers Correctly
OAuth2 integration should use the all-in-one method for simplicity and security. Manual code exchange should only be used when necessary (e.g., mobile apps with deep links).
Incorrect (manual implementation without SDK):
// Don't manually handle OAuth flow
async function loginWithGoogle() {
// Redirect user to Google manually
window.location.href = 'https://accounts.google.com/oauth/authorize?...';
}
// Manual callback handling
async function handleCallback(code) {
// Exchange code manually - error prone!
const response = await fetch('/api/auth/callback', {
method: 'POST',
body: JSON.stringify({ code })
});
}Correct (using SDK's all-in-one method):
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// All-in-one OAuth2 (recommended for web apps)
async function loginWithOAuth2(providerName) {
try {
const authData = await pb.collection('users').authWithOAuth2({
provider: providerName, // 'google', 'github', 'microsoft', etc.
// Optional: create new user data if not exists
createData: {
emailVisibility: true,
name: '' // Will be populated from OAuth provider
}
});
console.log('Logged in via', providerName);
console.log('User:', authData.record.email);
console.log('Is new user:', authData.meta?.isNew);
return authData;
} catch (error) {
if (error.isAbort) {
console.log('OAuth popup was closed');
return null;
}
throw error;
}
}
// Usage
document.getElementById('google-btn').onclick = () => loginWithOAuth2('google');
document.getElementById('github-btn').onclick = () => loginWithOAuth2('github');Manual code exchange (for React Native / deep links):
// Only use when all-in-one isn't possible
async function loginWithOAuth2Manual() {
// Get auth methods - PocketBase provides state and codeVerifier
const authMethods = await pb.collection('users').listAuthMethods();
const provider = authMethods.oauth2.providers.find(p => p.name === 'google');
// Store the provider's state and codeVerifier for callback verification
// PocketBase generates these for you - don't create your own
sessionStorage.setItem('oauth_state', provider.state);
sessionStorage.setItem('oauth_code_verifier', provider.codeVerifier);
// Build the OAuth URL using provider.authURL + redirect
const redirectUrl = window.location.origin + '/oauth-callback';
const authUrl = provider.authURL + encodeURIComponent(redirectUrl);
// Redirect to OAuth provider
window.location.href = authUrl;
}
// In your callback handler (e.g., /oauth-callback page):
async function handleOAuth2Callback() {
const params = new URLSearchParams(window.location.search);
// CSRF protection: verify state matches
if (params.get('state') !== sessionStorage.getItem('oauth_state')) {
throw new Error('State mismatch - potential CSRF attack');
}
const code = params.get('code');
const codeVerifier = sessionStorage.getItem('oauth_code_verifier');
const redirectUrl = window.location.origin + '/oauth-callback';
// Exchange code for auth token
const authData = await pb.collection('users').authWithOAuth2Code(
'google',
code,
codeVerifier,
redirectUrl,
{ emailVisibility: true }
);
// Clean up
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('oauth_code_verifier');
return authData;
}Configure OAuth2 provider (Admin UI or API):
// Via API (superuser only) - usually done in Admin UI
// IMPORTANT: Never hardcode client secrets. Use environment variables.
await pb.collections.update('users', {
oauth2: {
enabled: true,
providers: [{
name: 'google',
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET
}],
mappedFields: {
avatarURL: 'avatar' // Map OAuth field to collection field
}
}
});Reference: PocketBase OAuth2