Skip to content

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_roles restricts which roles the token is valid for
  • Sub-tokens cannot create other sub-tokens (privilege laundering prevention)
  • Token lookup uses crypto.timingSafeEqual to 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"
}
FieldTypeRequiredDescription
namestringYesHuman-readable label
descriptionstringNoOptional description
scopesstring[]NoAllowed operations: read, create, update, delete. null = inherit all
allowed_rolesstring[]NoRestrict to a subset of user's roles. null = all user roles
expires_atISO 8601NoToken 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 token field is only returned at creation. It cannot be retrieved later.

Validation Rules:

  1. SCOPE-04: Caller must NOT be using a sub-token (prevent privilege laundering)
  2. SCOPE-01: allowed_roles must be a subset of accountability.roles — cannot grant roles not held
  3. SCOPE-03: scopes must only contain read, 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_hash is never returned. Only token_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 ID
  • sub_token_id = token record ID
  • roles = scoped to allowed_roles if 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 used
  • expired — Past expires_at (token record returned for logging)
  • revoked — Token has revoked_at set
  • not_found — Hash not in DB

Data Model

odp_user_tokens

ColumnTypeDescription
idUUID PKToken identifier
user_idUUID FK → odp_usersOwner (CASCADE delete)
namestring(255)Human label
descriptiontextOptional description
token_hashstring(64)SHA-256 hex hash of plaintext token
token_prefixstring(8)Always odp:
scopesjsonArray of scope strings or null
allowed_rolesjsonArray of role UUIDs or null
expires_attimestampOptional expiry
last_used_attimestampLast successful authentication
revoked_attimestampRevocation timestamp
created_attimestamp

Indexes:

  • idx_user_tokens_prefix on token_prefix
  • idx_user_tokens_user_id on user_id

Events

EventDescription
sub-tokens.createToken created; payload includes id, user_id, name, scopes, allowed_roles, expires_at
sub-tokens.revokeToken revoked; payload includes id, user_id, revoked_by

ODP Internal API Documentation