Personal access tokens (PATs)
Long-lived API tokens for CLI / scripts
A user wants to call your API from a script or CLI without re-authenticating each time. OAuth2 device-code flow works but is interactive. For non-interactive: Personal Access Tokens (PATs).
What a PAT is
A token the user generates in your app, scope-limited, used as Authorization: Bearer <pat>.
curl -H "Authorization: Bearer pat_1abc..." https://your-api/...Storage
CREATE TABLE personal_access_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
identity_id UUID NOT NULL,
name TEXT NOT NULL, -- "my-laptop CLI"
token_hash TEXT NOT NULL, -- SHA-256 of the actual token
scopes JSONB NOT NULL, -- array of scope strings
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ, -- optional
revoked_at TIMESTAMPTZ
);
CREATE INDEX ix_pat_hash ON personal_access_tokens(token_hash) WHERE revoked_at IS NULL;Store hash only, never plaintext. Even DB admin can't recover the token.
Generation
async function createPat(identityId: string, name: string, scopes: string[]) {
const raw = "pat_" + crypto.randomBytes(32).toString("base64url");
const hash = sha256(raw);
const id = crypto.randomUUID();
await db.insert(pats).values({
id,
identity_id: identityId,
name,
token_hash: hash,
scopes,
});
// Show `raw` to user ONCE; never again.
return { id, token: raw };
}UI:
const result = await createPat(...);
return (
<div>
<p>Your new token:</p>
<pre>{result.token}</pre>
<p>⚠️ Copy this now. You won't see it again.</p>
</div>
);Validation
async function validatePat(token: string) {
if (!token.startsWith("pat_")) return null;
const hash = sha256(token);
const pat = await db`
SELECT * FROM personal_access_tokens
WHERE token_hash = ${hash}
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
`.first();
if (!pat) return null;
await db`UPDATE personal_access_tokens SET last_used_at = NOW() WHERE id = ${pat.id}`;
return {
identity_id: pat.identity_id,
scopes: pat.scopes,
};
}Use this in your API:
app.use(async (req, res, next) => {
const auth = req.headers.authorization?.slice(7);
if (auth?.startsWith("pat_")) {
const pat = await validatePat(auth);
if (pat) {
req.user = await kratos.getIdentity(pat.identity_id);
req.scopes = pat.scopes;
return next();
}
}
// Fall through to OAuth2 bearer validation
next();
});Scopes
PATs are usually scope-limited:
<form action={createPat}>
<input name="name" placeholder="Token name" />
<fieldset>
<legend>Permissions</legend>
<label><input type="checkbox" name="scopes" value="repo:read" /> Read repos</label>
<label><input type="checkbox" name="scopes" value="repo:write" /> Write repos</label>
<label><input type="checkbox" name="scopes" value="admin:read" /> Read admin</label>
</fieldset>
<button>Create token</button>
</form>User picks. Default to least privilege.
Listing user's PATs
// app/settings/tokens/page.tsx
const tokens = await db`SELECT id, name, scopes, created_at, last_used_at, expires_at FROM personal_access_tokens WHERE identity_id = ${userId} AND revoked_at IS NULL`;Show:
- Name.
- Scopes.
- Created date.
- Last used.
- "Revoke" button.
Revocation
async function revokePat(id: string, identityId: string) {
await db`UPDATE personal_access_tokens SET revoked_at = NOW() WHERE id = ${id} AND identity_id = ${identityId}`;
}User-revoke: from their settings page. Admin-revoke: support tool / Athena.
Expiration
Optional but recommended for high-security:
ALTER TABLE personal_access_tokens ADD COLUMN expires_at TIMESTAMPTZ;Default: 1 year. User can override (max 5y).
<select name="expires_in">
<option value="30d">30 days</option>
<option value="90d">90 days</option>
<option value="1y" selected>1 year</option>
<option value="never">No expiration</option>
</select>Email reminder before expiration.
Rotation
For automated tools, rotation hard. Best to issue a new token before old expires:
// CLI tool
const expiresIn = (token.expires_at - new Date()) / 1000;
if (expiresIn < 7 * 86400) { // < 7 days
console.warn(`Your token expires in ${Math.floor(expiresIn / 86400)} days.`);
console.warn(`Generate a new one at: https://your-app.com/settings/tokens`);
}Audit log
Every PAT action logged:
INSERT INTO security_audit (event_type, actor_id, identity_id, metadata)
VALUES (
'pat_used',
$pat_id, -- which PAT
$identity_id,
'{"endpoint": "/api/...", "scopes_used": [...]}'
);User can see "this token used [endpoint] [N] times" in their settings.
Detection of leak
If a user accidentally commits their PAT to GitHub:
- Use GitHub's secret scanning (it auto-detects common patterns).
- Your token prefix
pat_should be unique enough to scan. - Send notification to user when found.
PAT vs OAuth2 client credentials
| PAT | Client Credentials | |
|---|---|---|
| Acts as | A user | A machine |
| Scopes | User-chosen | Admin-configured |
| Revocation | User self-service | Admin |
| Use case | Personal CLI, scripts | Background workers |
For service accounts (running on a server, not tied to a person): use Client Credentials, NOT PATs.