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:write and notes_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

  1. Open Settings → API tokens (under Access). Only superusers see this screen.
  2. Fill in a Name. Optionally set Expires in (days), and tick any Scopes (none means full rights).
  3. By default the token is yours. To make one for another account, fill in Owner collection (default _superusers) and Owner (id or email).
  4. 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-refresh and impersonate refuse 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 tokens with a count when it deletes any, and tokens: delete expired as 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 its Owner, Scopes and Audience. tokens.HasScope(e, "x") is true for PocketBase auth tokens and unscoped API tokens.
  • Without importing this package. Read the request store keys tokens.scopes and tokens.id, or the X-Token-Scope-* headers.
  • Audience. A token created with CreateOptions.Audience (OAuth Server sets it from the RFC 8707 resource parameter) only works under that URL's path, plus /_pocker/whoami; elsewhere it gets 403. When the audience has a path (like https://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 with WithScopes), not the endpoint itself.
  • Limit in-process requests. tokens.WithScopes(ctx, scopes) returns a context whose requests are checked like a token limited to scopes, whatever credential they carry: 403 where the scopes don't reach, and only those scopes in the X-Token-Scope-* headers and HasScope. Requests made inside them with the same context inherit the limit, and a nested WithScopes only 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), and Scope.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 _tokens collection: 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.go runs one app where superusers and users may own tokens, with a hello scope for GET /api/hello. Try it with go run ./example serve and go run ./example token create --superuser [email protected] --name ci (logins from devusers: [email protected] / password, [email protected] / password).

License

MIT