Documentation

Token permissions

Permissions, roles, and per-ecosystem scope restrictions.

Source: api.packr.blueforge.studio/-/docs/tokens

Part 7: Token Permission Model and Roles

Previous: docs/06-oauth.md


Overview

Packr tokens carry fine-grained permissions. Every token has a role (a preset bundle of permissions) and an optional set of scope restrictions (limiting which npm scopes the token can access). This allows you to issue least-privilege tokens for CI pipelines, automated tools, and individual contributors.


Permission Types

PermissionWhat it allows
readDownload and install packages, fetch metadata
publishPublish new package versions
unpublishRemove published versions (within 72-hour window)
adminFull access: read, publish, unpublish, manage tokens, manage users

Permissions are checked by the RequirePermission middleware before any protected handler runs.


Preset Roles

Roles are named bundles of permissions. Use roles instead of specifying individual permissions to avoid mistakes.

RolePermissionsTypical Use Case
ci-readonlyreadGitHub Actions install step, pnpm install in CI
ci-publishread, publishGitHub Actions publish step, automated release
maintainerread, publish, unpublishPackage maintainers who can release and retract
adminAllRegistry administrators, dashboard access

Scope Restrictions

A token can be restricted to one or more npm scopes. When scope restrictions are set, the token can only operate on packages within those scopes.

  • Empty scopes — token applies to all packages (backward compatible)
  • One or more scopes — token is restricted to exactly those scopes

Examples:

Token scopesCan access
(empty)@myorg/pkg-a, @other/pkg-b, any package
@myorgOnly @myorg/* packages
@myorg, @internalOnly @myorg/* and @internal/* packages

Scope restrictions are enforced at the route level for publish, unpublish, deprecate, and dist-tag operations.


JWT Claims Structure

Every access token is a JWT. The claims include the user identity and the token's permissions:

type TokenClaims struct {
    OrgID       string   `json:"org_id"`
    UserID      string   `json:"user_id"`
    Scope       string   `json:"scope,omitempty"`       // legacy single scope
    PkgLimit    int      `json:"pkg_limit"`
    Permissions []string `json:"permissions,omitempty"` // e.g. ["read","publish"]
    Scopes      []string `json:"scopes,omitempty"`      // scope restrictions
    Role        string   `json:"role,omitempty"`        // preset role name
    jwt.RegisteredClaims
}

Example decoded JWT payload:

{
  "org_id": "default",
  "user_id": "ci-bot",
  "permissions": ["read", "publish"],
  "scopes": ["@blueforge-studio"],
  "role": "ci-publish",
  "exp": 1746000000,
  "iat": 1743408000
}

Token Creation

Via API

POST /api/v1/admin/tokens
Cookie: packr_session=<session>
Content-Type: application/json

{
  "name": "ci-publish-token",
  "role": "ci-publish",
  "scopes": ["@blueforge-studio"],
  "expires_in_days": 30
}

Response (201 Created):

{
  "id": 42,
  "name": "ci-publish-token",
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "role": "ci-publish",
  "permissions": ["read", "publish"],
  "scopes": ["@blueforge-studio"],
  "expires_at": "2026-05-10T00:00:00Z",
  "created_at": "2026-04-10T00:00:00Z"
}

The raw token value is only returned at creation time. Store it immediately.

Via CLI

# Create a read-only CI token scoped to @myorg
packr-cli token create --name ci-read --role ci-readonly --scope @myorg

# Create a publish token scoped to @myorg
packr-cli token create --name ci-publish --role ci-publish --scope @myorg

# Create an admin token (no scope restriction)
packr-cli token create --name admin-token --role admin

# Create with custom expiry (90 days)
packr-cli token create --name long-lived --role ci-readonly --expires-in-days 90

Token List Output

$ packr-cli token list

ID    Name              Role          Scopes              Expires       Created
42    ci-publish-token  ci-publish    @blueforge-studio   2026-05-10    2026-04-10
43    ci-read-token     ci-readonly   @blueforge-studio   2026-05-10    2026-04-10
1     admin-token       admin         (all)               2026-07-09    2026-04-10

Backward Compatibility

Existing tokens without permissions or role claims are treated as admin tokens. This preserves backward compatibility — previously created tokens continue to work without modification.

The permission check logic:

// If the token has no permissions claim, grant all (legacy behavior)
if len(claims.Permissions) == 0 {
    return true // backward compat: old tokens are admin
}
// Otherwise check the required permission
return slices.Contains(claims.Permissions, required)

Token Lifecycle

Default Expiry

Tokens expire after 30 days by default. Set expires_in_days at creation time to override:

Use caseRecommended expiry
CI/CD tokens30-90 days
Developer tokens30 days
Long-lived service accounts90 days max
Emergency admin access1 day

Rotation Strategy

To rotate a CI token with zero downtime:

  1. Create the new token:

    packr-cli token create ci-publish-v2 --role ci-publish --scope @myorg
    
  2. Update the secret in your CI/CD system (GitHub, GitLab, etc.) with the new token value.

  3. Verify pipelines are working with the new token.

  4. Revoke the old token:

    packr-cli token revoke 42
    

This ensures there is no gap in CI access during rotation. Always revoke lastrotate and token create both leave the existing credential working precisely so that step 3 can fail safely.

Rotating without the token passing through a log

token create prints the token to stdout so it can be piped. That also means anything running it — a CI job, a terminal transcript, an AI agent — captures a live credential. Use --output-file when you would rather it never be displayed:

packr-cli token create ci-publish-v2 --role ci-publish --scope @myorg \
  --output-file "$f"
gh secret set PACKR_TOKEN < "$f" && rm -f "$f"

packr-cli rotate never prints a token at all — it writes the new value straight into ~/.packr/credentials.json and ~/.npmrc. It also accepts --output-file, for the common case where the rotation needs to end in a CI secret:

f=$(mktemp); trap 'rm -f "$f"' EXIT
packr-cli rotate --scope @myorg --name rotated-2026-09 --output-file "$f"
gh secret set PACKR_TOKEN < "$f"
packr-cli token list           # find the old id
packr-cli token revoke <old>   # only once the new one is confirmed working

Prefer rotate when replacing a credential that is already in use, and token create when minting an additional one.

Revoking a token you can no longer authenticate as

packr-cli token revoke <id> normally deletes the token as its owner. If that returns 401 or 403, it usually does not mean you lack authority — it means your own credential can no longer authenticate against the admin API. The common cause is a token issued before the last JWT_SECRET rotation: publishing authenticates by hash lookup in the tokens table and still works, while admin routes verify the JWT signature and reject it. The credential most in need of revoking is then the one its owner is least able to revoke.

The CLI detects this and retries through the operator-secret route:

packr-cli token revoke 19 --super-admin-token <token> --reason "exposed in a log"
# or set PACKR_SUPER_ADMIN_TOKEN

That path posts {"id": N} to /api/v1/admin/tokens/revoke, which deletes the row. Deleting is what actually stops a legacy token, because the hash lookup — not the signature — is the path it authenticates on.

SituationCommand
Ordinary revoke, your login workspackr-cli token revoke <id>
Token has a jti and you hold the valuePOST /api/v1/admin/tokens/revoke with {"token": "..."}
Legacy token, no jti, admin API rejects youpackr-cli token revoke <id> --super-admin-token ...

Revoking by jti adds the id to a denylist. Revoking by row id deletes the credential outright. Tokens issued before jti claims existed have no id to deny, so the row-id form is the only one that works on them — previously the documented remedy was rotating JWT_SECRET, which invalidates every other token at the same time.


Best Practices for CI Token Management

Separate read and publish tokens:

# Read-only for install steps
packr-cli token create --name ci-install --role ci-readonly --scope @myorg

# Publish-capable for release steps
packr-cli token create --name ci-release --role ci-publish --scope @myorg

Store them as separate secrets:

gh secret set PACKR_TOKEN --org my-org < ci-install-token
gh secret set PACKR_TOKEN_PUBLISH --org my-org < ci-release-token

Use them in workflows:

# Install step
- name: Install dependencies
  env:
    PACKR_TOKEN: ${{ secrets.PACKR_TOKEN }}
  run: pnpm install

# Publish step (release workflow only)
- name: Publish package
  env:
    PACKR_TOKEN: ${{ secrets.PACKR_TOKEN_PUBLISH }}
  run: npm publish

Never share tokens across repositories. Each repository or pipeline should have its own token so you can revoke access to one without affecting others.

Scope tokens tightly. A ci-publish token for @myorg can only publish to @myorg/*. Even if the token is leaked, it cannot be used to publish to other scopes or access admin functions.


Next: docs/08-cli-auth.md — CLI Authentication and Credential Management