Personal Access Tokens
PAT lifecycle, the trov_ prefix convention, SHA-256 hashing, and how MCP requests authenticate without sessions.
Personal Access Tokens (PATs) authenticate MCP requests from tools like Claude Code. They are long-lived bearer tokens that bypass the session/cookie system entirely. PATs identify the user; the MCP server resolves the tenant from context.
Token Format
Every PAT follows this format:
trov_<40 hex characters>
Total length: 45 characters. The trov_ prefix makes tokens identifiable in logs, environment variables, and secret scanners. The 40 hex characters come from 20 cryptographically random bytes (randomBytes(20).toString("hex")).
Lifecycle
Creation
Users create PATs through the tRPC pat.create endpoint:
- The client sends a
name(required, 1-100 chars) and optionalexpiresInDays(1-365) - The server generates a random token with
trov_prefix - The token is SHA-256 hashed using the Web Crypto API (
crypto.subtle.digest) - The hash, a prefix for display (
trov_XXXX), name, user ID, and optional expiration are stored in thepersonal_access_tokentable - The raw token is returned to the client once -- it cannot be retrieved again
const rawToken = generateToken(); // trov_ + 40 hex chars
const tokenHash = await hashToken(rawToken); // SHA-256
await createPAT({ id, userId, name, tokenHash, tokenPrefix: rawToken.slice(0, 9), expiresAt });
return { id, token: rawToken }; // shown once
Validation
MCP requests authenticate via the authenticateRequest() function in packages/mcp/src/auth.ts:
- Extract the
Authorization: Bearer <token>header - Verify the token starts with
trov_ - SHA-256 hash the raw token
- Query
personal_access_tokenjoined touserwheretokenHashmatches andrevokedAtis null - Check
expiresAt(if set) is in the future - Fire-and-forget update to
lastUsedAtfor monitoring - Return
{ userId, userName, userEmail }or null
The validation performs a constant-time comparison via the database lookup (the hash is the lookup key, not the raw token).
Revocation
Users revoke PATs through the pat.revoke endpoint. Revocation is a soft delete -- revokedAt is set to the current timestamp. The token row is preserved for audit purposes but excluded from validation queries (WHERE revoked_at IS NULL).
Only the owning user can revoke their tokens. The query filters on both userId and token id.
Listing
The pat.list endpoint returns all non-revoked PATs for the current user, showing name, tokenPrefix (first 9 chars for identification), lastUsedAt, expiresAt, and createdAt. The full token hash is never returned to the client.
Schema
The personal_access_token table is defined in packages/db/src/schema/mcp.ts:
| Column | Type | Purpose |
|---|---|---|
id | TEXT (PK) | UUID generated at creation |
userId | TEXT (FK to user) | Token owner |
name | TEXT | User-provided label |
tokenHash | TEXT (unique) | SHA-256 hash of raw token |
tokenPrefix | TEXT | First 9 chars for display (trov_XXXX) |
lastUsedAt | TIMESTAMP | Updated on each successful validation |
expiresAt | TIMESTAMP | Optional expiration |
revokedAt | TIMESTAMP | Soft-delete marker |
createdAt | TIMESTAMP | Creation time |
Indexes: pat_userId_idx on userId, pat_tokenHash_idx on tokenHash.
Why PATs Are User-Scoped, Not Tenant-Scoped
PATs identify who is making the request, not which tenant they are operating on. When an MCP request arrives:
- The PAT resolves the user
- The MCP server determines the active tenant from context (the user's org membership)
withTenantContext()applies RLS for that tenant
This design means a single PAT works across all organizations the user belongs to. The alternative -- tenant-scoped tokens -- would require users to manage one token per organization and reconfigure their MCP client when switching.
Because PATs are user-scoped, the PAT tRPC router uses protectedProcedure (requires auth, no org context) instead of authorizedProcedure (requires auth + org + CASL). The personal_access_token table has no organization_id column and no RLS policies.
Security Model
| Concern | Mitigation |
|---|---|
| Token storage | Only SHA-256 hash stored; raw token shown once at creation |
| Token leakage | trov_ prefix enables secret scanners; lastUsedAt reveals unauthorized usage |
| Token compromise | Optional expiration (1-365 days); instant revocation |
| Brute force | 40 hex chars = 160 bits of entropy; SHA-256 hashing adds computational cost |
| Scope creep | PATs grant the user's full permissions (no fine-grained scopes); see risks below |
Known Risk: No Scope Restrictions
PATs currently grant the same access as a session cookie -- full API access for the user. Fine-grained scopes (e.g., read-only, specific-org) are deferred. This is acceptable for the MVP where the only PAT consumer is Claude Code operating with the user's full intent.
OAuth 2.1 (Deferred)
The MCP specification supports OAuth 2.1, which is the proper long-term solution for Claude Web/Chat/Work integration. OAuth 2.1 provides short-lived tokens with refresh capability, reducing the blast radius of a leaked token. This is tracked in TRO-48 and deferred to post-MVP. PATs are simpler, immediately reliable, and sufficient for Claude Code's HTTP transport.
Related Pages
- Session Management -- the session-based auth path (browser)
- Sign-In Flow -- how browser sessions are created (contrast with PATs)
- Data & Storage -- Schema Design -- Reference Data --
personal_access_tokenclassified as global - Data & Storage -- Query Patterns -- Non-Tenant Queries -- how PAT queries use bare
db