Appearance
Sub-Tokens (API Keys)
Overview
Sub-tokens (also called API keys or user tokens) are long-lived credential tokens that a user can create to give programmatic access to the API without sharing their main credentials. They are stored as SHA-256 hashes — the plaintext is only shown once at creation time.
Key Properties
- Format:
odp:<32-random-hex-bytes>(e.g.,odp:a1b2c3...) - Stored as SHA-256 hash in
odp_user_tokens.token_hash - Scopes restrict which operations the token can perform
allowed_rolesrestricts which roles the token is valid for- Sub-tokens cannot create other sub-tokens (privilege laundering prevention)
- Token lookup uses
crypto.timingSafeEqualto prevent timing attacks
Endpoints
POST /users/me/tokens
Create a new sub-token for the authenticated user.
Auth required: Yes (must be authenticated via JWT — NOT via a sub-token)
Request Body:
json
{
"name": "CI/CD Pipeline",
"description": "Used by GitHub Actions",
"scopes": ["read", "create", "update"],
"allowed_roles": ["role-uuid-1"],
"expires_at": "2025-12-31T23:59:59.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Human-readable label |
description | string | No | Optional description |
scopes | string[] | No | Allowed operations: read, create, update, delete. null = inherit all |
allowed_roles | string[] | No | Restrict to a subset of user's roles. null = all user roles |
expires_at | ISO 8601 | No | Token expiry datetime |
Response (token shown ONCE):
json
{
"data": {
"id": "token-uuid",
"name": "CI/CD Pipeline",
"description": "Used by GitHub Actions",
"scopes": ["read", "create", "update"],
"allowed_roles": ["role-uuid-1"],
"expires_at": "2025-12-31T23:59:59.000Z",
"created_at": "2024-01-01T00:00:00.000Z",
"token": "odp:a1b2c3d4e5f6..."
}
}Important: The
tokenfield is only returned at creation. It cannot be retrieved later.
Validation Rules:
- SCOPE-04: Caller must NOT be using a sub-token (prevent privilege laundering)
- SCOPE-01:
allowed_rolesmust be a subset ofaccountability.roles— cannot grant roles not held - SCOPE-03:
scopesmust only containread,create,update,delete
GET /users/me/tokens
List all sub-tokens for the authenticated user.
Auth required: Yes
Response:
json
{
"data": [
{
"id": "token-uuid",
"user_id": "user-uuid",
"name": "CI/CD Pipeline",
"description": "Used by GitHub Actions",
"token_prefix": "odp:",
"scopes": ["read", "create", "update"],
"allowed_roles": ["role-uuid-1"],
"expires_at": "2025-12-31T23:59:59.000Z",
"last_used_at": "2024-06-15T08:30:00.000Z",
"revoked_at": null,
"created_at": "2024-01-01T00:00:00.000Z"
}
]
}Note:
token_hashis never returned. Onlytoken_prefix(odp:) is exposed.
DELETE /users/me/tokens/:id
Revoke a sub-token owned by the authenticated user.
Auth required: Yes
URL Parameters:
id— Token UUID
Response: 204 No Content
Business Logic:
- IDOR-safe: ownership is verified before calling revoke (token must belong to the authenticated user)
- Revocation is idempotent — already-revoked tokens return 204 without error
- Sets
revoked_at = now()
GET /tokens (Admin)
List all sub-tokens across all users with user_email join.
Auth required: Admin access
Query Parameters:
user_id— Filter by user UUID
Response:
json
{
"data": [
{
"id": "token-uuid",
"user_id": "user-uuid",
"user_email": "user@example.com",
"name": "Production Key",
"token_prefix": "odp:",
"scopes": null,
"allowed_roles": null,
"expires_at": null,
"last_used_at": null,
"revoked_at": null,
"created_at": "2024-01-01T00:00:00.000Z"
}
]
}DELETE /tokens/:id (Admin)
Revoke any user's token (bypasses ownership check).
Auth required: Admin access
Response: 204 No Content
Side Effect: Emits sub-tokens.revoke action event.
GET /users/:id/tokens (Admin)
List tokens for a specific user.
Auth required: Admin access
Response: Same as GET /users/me/tokens but for the specified user ID.
Authentication Using Sub-Tokens
To authenticate with a sub-token, send it in the Authorization header:
Authorization: Bearer odp:a1b2c3d4e5f6...The middleware detects the odp: prefix, computes SHA-256(token), and looks up the hash in odp_user_tokens. On success, it builds an Accountability object with:
user= token owner's user IDsub_token_id= token record IDroles= scoped toallowed_rolesif set, otherwise user's full roles- Scopes constrain what operations are permitted downstream
After a successful lookup, last_used_at is updated asynchronously (non-blocking).
Token Lifecycle States
Lookup returns:
ok— Token is valid and can be usedexpired— Pastexpires_at(token record returned for logging)revoked— Token hasrevoked_atsetnot_found— Hash not in DB
Data Model
odp_user_tokens
| Column | Type | Description |
|---|---|---|
id | UUID PK | Token identifier |
user_id | UUID FK → odp_users | Owner (CASCADE delete) |
name | string(255) | Human label |
description | text | Optional description |
token_hash | string(64) | SHA-256 hex hash of plaintext token |
token_prefix | string(8) | Always odp: |
scopes | json | Array of scope strings or null |
allowed_roles | json | Array of role UUIDs or null |
expires_at | timestamp | Optional expiry |
last_used_at | timestamp | Last successful authentication |
revoked_at | timestamp | Revocation timestamp |
created_at | timestamp |
Indexes:
idx_user_tokens_prefixontoken_prefixidx_user_tokens_user_idonuser_id
Events
| Event | Description |
|---|---|
sub-tokens.create | Token created; payload includes id, user_id, name, scopes, allowed_roles, expires_at |
sub-tokens.revoke | Token revoked; payload includes id, user_id, revoked_by |