Olympus Docs
CookbookTokens & OAuth2

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

PATClient Credentials
Acts asA userA machine
ScopesUser-chosenAdmin-configured
RevocationUser self-serviceAdmin
Use casePersonal CLI, scriptsBackground workers

For service accounts (running on a server, not tied to a person): use Client Credentials, NOT PATs.

On this page