Tokens
Long-lived API tokens for your app: secrets that start with pbt_, for CI, deploys, scripts and agents. Each token
belongs to an account and acts as that account. It can be limited to scopes, given an expiry, and revoked on its own.
Send one as Authorization: Bearer pbt_…. Ordinary PocketBase auth tokens keep working unchanged.
Install · Configure · Use · Integrations · Example
Install
Tokens is built into pocketone and always on. There's nothing to install or turn on.
In your own Go build, register it before the app starts:
app := pocketbase.New()
tokens.MustRegister(app, tokens.Config{})
app.Start()
To use $tokens in pb_hooks, bind it in jsvm's OnInit (the stock binary does):
jsvm.MustRegister(app, jsvm.Config{OnInit: func(vm *goja.Runtime) { tokens.BindJS(vm, app) }})
The module is github.com/pockethost/pocketone/ext/tokens.
Configure
There is no pocketone.yaml section, flag or environment variable for Tokens. In pocketone:
- any account (a superuser, or a record of any auth collection) can own tokens;
- a token never expires unless you give it an expiry.
A Go build can narrow both (see Go config).
Scopes
A token without scopes has its owner's full rights. A token with scopes works only on the requests its scopes cover,
plus /_pocker/whoami; anywhere else it gets 403. Extensions and your own pb_hooks register the scopes. In
pocketone the built-in ones are:
| Scope | From | Allows | Who can hold it |
|---|---|---|---|
deploy |
Deploy | Deploy code (hooks, migrations, public files, packages), read files and logs | superusers |
deploy:read |
Deploy | Read deployed files and stream logs | superusers |
pocketone token scopes lists every scope your app has, with its paths.
Your own scopes
Declare a scope at the top level of a pb_hooks file. It then shows up in Settings → API tokens and on the
OAuth Server consent page:
// pb_hooks/scopes.pb.js
$tokens.registerScope({
name: 'notes:read', // lower-case letters, digits, ":" and "_"
description: 'Read notes', // shown in the token screen and on the consent page
paths: ['/api/collections/notes/records'], // URL prefixes where a token holding it works
methods: ['GET'], // optional: only these methods (GET covers HEAD)
})
$tokens.registerScope({ name: 'notes:write', description: 'Change notes', paths: ['/api/collections/notes/records'] })
collections: ['users']limits which accounts may hold the scope.- Registering a name again merges its paths, methods and collections, so reloading hooks is safe.
- Two names whose headers would be the same (
notes:writeandnotes_write) can't both be registered.
Check a scope on your own route:
routerAdd('POST', '/api/notes/archive', (e) => { /* ... */ }, $apis.requireAuth(), $tokens.requireScope('notes:write'))
$tokens.requireScope refuses a signed-out request, and a token that doesn't hold the scope. Inside a handler,
$tokens.hasScope(e, 'notes:write') tells you the same.
Check a scope in a collection API rule. Every signed-in request carries X-Token-Scope-<Name>: 1 for each scope it
holds, and rules read it as @request.headers.x_token_scope_<name> (: becomes _):
@request.auth.id = owner && @request.headers.x_token_scope_notes_write = "1"
- A PocketBase auth token or an unscoped API token holds every registered scope (except ones its collection can't hold).
- A scoped token holds only its own scopes. A signed-out request holds none.
- Headers a client sends with that prefix are always dropped, so they can't be forged.
$tokens.ruleField('notes:write') returns the rule field for a name, and $tokens.scopes() lists every registered
scope with its header and rule field.
Use
From the dashboard
- Open Settings → API tokens (under Access). Only superusers see this screen.
- Fill in a Name. Optionally set Expires in (days), and tick any Scopes (none means full rights).
- By default the token is yours. To make one for another account, fill in Owner collection (default
_superusers) and Owner (id or email). - Click Create token. Copy the token now: it's shown only once and never stored.
The list shows each token's name, its first characters, owner, scopes, expiry and when it was last used (time and IP).
Tokens issued by OAuth Server carry an oauth: <client id> label. Click Revoke and
confirm to delete one; anything using it stops working at once.
App users who aren't superusers manage their own tokens through the REST API.
From the command line
The token command works on the local data dir, like superuser, so you can set up tokens before the first serve:
pocketone token create --superuser [email protected] --name ci --scope deploy --ttl 720h # prints the secret
pocketone token create --collection users --record [email protected] --name script
pocketone token list # every token, or one owner's with an owner flag
pocketone token revoke <id> [<id>...]
pocketone token revoke --all --superuser [email protected]
pocketone token scopes
Pick the owner with --superuser <email>, or --collection <name> --record <id or email>. --scope can be repeated;
without --ttl the token never expires.
Using a token
curl -H "Authorization: Bearer pbt_…" https://myapp.example.com/api/collections/notes/records
- An invalid, revoked or expired token leaves the request signed out, like an invalid PocketBase token.
- A scoped token used outside its scopes gets
403("This API token's scopes (…) don't allow this request."). - A token can't be exchanged for a PocketBase auth token (
auth-refreshandimpersonaterefuse it), so revoking a token really ends its access. - Expired tokens are deleted once a day. Deleting an account deletes its tokens.
In the logs
- A rejected token is logged at debug level:
tokens: rejected API token. - The daily cleanup logs
tokens: deleted expired tokenswith a count when it deletes any, andtokens: delete expiredas a warning if it fails.
Integrations
Works with
| Extension | How they fit |
|---|---|
| OAuth Server | Its access tokens are API tokens, issued with the client's id, the granted scopes and, when asked for, an audience. They show up under Settings → API tokens and can be revoked there. OAuth Server needs Tokens on the app. |
| MCP | An agent's OAuth sign-in to /mcp is an API token bound to /mcp that never expires; revoke it to disconnect the agent. Tool calls run as the token's owner. A full-access token's calls hold every registered scope; a token limited to scopes on the consent page has its calls checked like requests with those scopes (WithScopes). |
| Deploy | Registers deploy and deploy:read; a deploy token is what CI uses. |
| Remotes | A password pocketone login creates an API token and logout deletes it. In CI, put a token in POCKETONE_TOKEN (or POCKETONE_TOKEN_<REMOTE>). /_pocker/whoami reports a token's id and scopes, whatever its scopes. |
| Pocker | Add tokens.New(tokens.Config{}) to the host's extensions and every instance gets its own tokens, routes and screen. Per instance, extensions: {tokens: {enabled: false}} turns it off and collections: [...] picks who may own tokens. The stock pocketone binary doesn't add it to pocker instances. |
Building on Tokens
Register a scope from Go. Extensions register their scopes on the app, usually on bootstrap, and guard their routes:
tk := tokens.For(app)
tk.MustRegisterScope(tokens.Scope{
Name: "notes:write",
Description: "Change notes",
Paths: []string{"/api/collections/notes/records"},
Methods: []string{"POST", "PATCH", "DELETE"}, // optional
Collections: []string{"users"}, // optional: who may hold it
})
se.Router.POST("/api/notes/archive", h).Bind(apis.RequireAuth(), tokens.RequireScope("notes:write"))
- Guards. A Go scope can add
Match func(*core.RequestEvent) bool: a token with the scope also works on any request it returns true for. It runs before the owner is loaded, so it may look only at the request. - Shared scopes. Registering a name again merges its paths, methods, guards and collections, so several extensions can share one scope.
- Read the token on a request.
tokens.FromRequest(e)returns the token a request used (nil for a PocketBase auth token), with itsOwner,ScopesandAudience.tokens.HasScope(e, "x")is true for PocketBase auth tokens and unscoped API tokens. - Without importing this package. Read the request store keys
tokens.scopesandtokens.id, or theX-Token-Scope-*headers. - Audience. A token created with
CreateOptions.Audience(OAuth Server sets it from the RFC 8707resourceparameter) only works under that URL's path, plus/_pocker/whoami; elsewhere it gets403. When the audience has a path (likehttps://host/mcp), the token works there whatever its scopes: the scopes limit what that endpoint does on the token's behalf (MCP passes them on withWithScopes), not the endpoint itself. - Limit in-process requests.
tokens.WithScopes(ctx, scopes)returns a context whose requests are checked like a token limited toscopes, whatever credential they carry:403where the scopes don't reach, and only those scopes in theX-Token-Scope-*headers andHasScope. Requests made inside them with the same context inherit the limit, and a nestedWithScopesonly narrows it. No client value can set it.tokens.ScopesFromContext(ctx)reads it.Reaches(scopes, method, pattern)says whether scopes could cover a route pattern ({name}segments match anything), andScope.Infrastructure()whether a scope only reaches/_pocker/routes.
Go API. tokens.For(app) returns the service: Create(owner, CreateOptions{Name, Scopes, Expires, ClientId, Audience}) returns the secret and record; also Verify(secret), List(owner), Get(id), Revoke(id),
RevokeAll(owner), DeleteExpired(), RegisterScope, MustRegisterScope, Scopes(), Collections() and
AllowedCollection(c). tokens.BindJS(vm, app) adds $tokens; the extkit extension (tokens.New) also binds it.
JS. $tokens.registerScope({name, description, paths, methods, collections}), $tokens.requireScope(name),
$tokens.hasScope(e, name), $tokens.ruleField(name) and $tokens.scopes().
REST API
Any signed-in account; superusers see and manage everyone's.
| Route | What |
|---|---|
GET /api/tokens |
Your tokens. Superusers see all, or one owner's with ?ownerCollection=&owner=. |
POST /api/tokens |
{name, scopes, ttl | expires} (ttl in seconds, expires RFC 3339); superusers may add ownerCollection and owner. Returns the secret once. A scoped token can only create tokens within its own scopes. |
DELETE /api/tokens/{id} |
Revoke one. |
POST /api/tokens/revoke-all |
Revoke all of yours (superusers: {ownerCollection, owner}). |
GET /api/tokens/scopes |
The registered scopes, who may own tokens, and whether you can. |
Owners can also list, view and delete their own tokens through the records API of _tokens.
Example
example/main.go runs one app with API tokens: Settings → API tokens, the /api/tokens routes and the token command.
// Command example runs one PocketOne app with API tokens: Settings → API tokens in the dashboard, the /api/tokens
// routes, and the `token` command.
//
// go run ./example serve
// go run ./example token create --superuser [email protected] --name ci
// curl -H "Authorization: Bearer pbt_..." http://127.0.0.1:8090/api/collections
//
// Logins (ext/devusers, local testing only): dashboard [email protected] / password, app user [email protected] / password.
package main
import (
"log"
"net/http"
"ganigeorgiev.com/pocketbase/pocketbase"
"ganigeorgiev.com/pocketbase/pocketbase/apis"
"ganigeorgiev.com/pocketbase/pocketbase/core"
"github.com/pockethost/pocketone/ext/devusers"
"github.com/pockethost/pocketone/ext/tokens"
)
func main() {
app := pocketbase.New()
devusers.Register(app) // local testing: superuser [email protected] and user [email protected], password "password"
tk := tokens.MustRegister(app, tokens.Config{
Collections: []string{"_superusers", "users"}, // who may own tokens
})
// an extension's scope: a token limited to "hello" only authenticates under /api/hello; a JWT or unscoped token
// holds it too, and every request that holds it carries X-Token-Scope-Hello: 1 for rules and hooks
tk.MustRegisterScope(tokens.Scope{Name: "hello", Description: "Say hello", Paths: []string{"/api/hello"}, Methods: []string{"GET"}})
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.GET("/api/hello", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]any{"hello": e.Auth.Email()})
}).Bind(apis.RequireAuth(), tokens.RequireScope("hello"))
return se.Next()
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
Go config
tokens.MustRegister(app, tokens.Config{
Collections: []string{"_superusers", "users"}, // who may own tokens (default: every auth collection)
MaxTTL: 0, // cap on a token's lifetime (0: none)
})
With MaxTTL set, a token asked for with no expiry or a later one gets the cap instead. If you narrow Collections,
OAuth Server can only sign in accounts of those collections.
How it works
- Tokens live in the
_tokenscollection: owner (collection id and record id), name,hash(SHA-256 of the secret; the secret itself is never stored), display prefix, scopes, expiry, last use (time and IP), OAuth client id and audience. - The bearer check is a middleware that runs just before PocketBase's own auth middleware; a second one, right after it, sets the scope headers. Last use is written at most once a minute per token.
example/main.goruns one app where superusers and users may own tokens, with ahelloscope forGET /api/hello. Try it withgo run ./example serveandgo run ./example token create --superuser [email protected] --name ci(logins from devusers: [email protected] / password, [email protected] / password).
License
MIT
Questions and comments
Comments are a GitHub Discussions thread; sign in with GitHub to post.