Docs /features/api-keys

API Keys & Tokens

API token authentication for programmatic access with organization-scoped security

📋 Overview

API Tokens provide token-based authentication for programmatic access to Izri APIs, enabling CI/CD integration, automation scripts, and third-party applications. Tokens are now organization-scoped, providing better security isolation and role-based access control.

🚀 What's New (v2.0)

Organization-Scoped Tokens

  • Previously: Tokens were user-scoped (one user, one set of tokens)
  • Now: Tokens are org-scoped (per organization, with role-based permissions)
  • Breaking Change: Existing user-scoped tokens are no longer valid. Create org-scoped tokens instead.

Enhanced Security

  • Tokens are isolated by organization
  • Scope enforcement based on user's role within the organization
  • Audit logging for all token operations
  • Rate limiting per organization
  • Token rotation support

Features

✅ Create tokens with specific scopes
✅ Organization-based isolation
✅ Role-based scope enforcement
✅ Rate limiting per organization
✅ Audit logging with full context
✅ Token rotation (revoke old, create new atomically)
✅ Token expiration support

🏗️ Database Schema

apiTokens table (organization-scoped):

{
  id: string                    // Unique token ID
  organizationId: string        // Organization ownership
  name: string                  // Descriptive name
  hash: string                  // SHA-256 hashed token
  scopes: string[]              // Permissions array
  lastUsedAt: Date?             // Last validation timestamp
  expiresAt: Date?              // Expiration date (optional)
  revokedAt: Date?              // Revocation date (soft delete)
  createdAt: Date               // Creation timestamp
  createdBy: string             // User who created token
}

Indexes:

CREATE UNIQUE INDEX api_tokens_hash_key ON api_tokens(hash);
CREATE INDEX api_tokens_org_idx ON api_tokens(organization_id);
CREATE INDEX api_tokens_revoked_idx ON api_tokens(revoked_at);

🔐 Token Format

Token Structure

Tokens follow a simple, secure format:

izri_[64-char-hex-random-id]

Example:
izri_a7f8c2e91d5b4a9c3e6f7a2b8d0c1e5f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c

Components:

  • Prefix: izri_ (identifies tokens in logs and environments)
  • Payload: 64 hex characters (cryptographically secure random)

Token Generation

import crypto from 'node:crypto'

function generateToken(): { raw: string; hash: string } {
  // Generate 32 random bytes
  const random = crypto.randomBytes(32).toString('hex')
  const raw = `izri_${random}`
  
  // Hash for storage (SHA-256)
  const hash = crypto.createHash('sha256').update(raw).digest('hex')
  
  return { raw, hash }
}

Token Hashing (Storage)

// Tokens are hashed before database storage
const { raw, hash } = generateToken()

await db.insert(apiTokens).values({
  hash,  // Store hash, not raw token
  organizationId,
  scopes,
  // ...
})

// Client stores raw token
// Server validates by hashing incoming token and comparing with stored hash

🔑 API Endpoints (tRPC Router)

Create Token

Mutation: tokens.createToken

const result = await trpc.tokens.createToken.mutate({
  organizationId: 'org_123',
  name: 'CI/CD Pipeline',
  scopes: ['projects:read', 'tests:run'],
  expiresIn: 604800  // 7 days in seconds (optional)
})

// Response
{
  token: {
    id: 'token_abc123',
    organizationId: 'org_123',
    name: 'CI/CD Pipeline',
    createdAt: '2024-01-15T10:30:00Z',
    expiresAt: '2024-01-22T10:30:00Z',
    scopes: ['projects:read', 'tests:run']
  },
  rawToken: 'izri_...',  // ⚠️ Only shown once!
  message: 'Token created. Save the token now — it will not be shown again.'
}

Important: The raw token is only displayed once during creation. Store it securely immediately!

List Tokens

Query: tokens.listTokens

const result = await trpc.tokens.listTokens.query({
  organizationId: 'org_123'
})

// Response
{
  tokens: [
    {
      id: 'token_abc123',
      organizationId: 'org_123',
      name: 'CI/CD Pipeline',
      scopes: ['projects:read', 'tests:run'],
      lastUsedAt: '2024-01-15T11:45:00Z',
      expiresAt: '2024-01-22T10:30:00Z',
      createdAt: '2024-01-15T10:30:00Z'
    }
  ]
}

Note: Actual token hash is never returned after creation

Revoke Token

Mutation: tokens.revokeToken

await trpc.tokens.revokeToken.mutate({
  organizationId: 'org_123',
  tokenId: 'token_abc123'
})

// Response
{ success: true }

Effects:

  • Token immediately becomes invalid
  • Cannot be restored
  • All requests using token will be rejected
  • Audit logged

Rotate Token

Mutation: tokens.rotateToken

const result = await trpc.tokens.rotateToken.mutate({
  organizationId: 'org_123',
  tokenId: 'token_abc123',
  newName: 'Updated CI/CD Token',  // optional
  expiresIn: 604800,               // optional
  scopes: ['projects:read']        // optional
})

// Response
{
  token: {
    id: 'token_def456',  // New token ID
    organizationId: 'org_123',
    name: 'Updated CI/CD Token',
    createdAt: '2024-01-15T12:00:00Z',
    expiresAt: '2024-01-22T12:00:00Z',
    scopes: ['projects:read']
  },
  rawToken: 'izri_...',  // New raw token (shown once)
  message: 'Token rotated. Save the new token now — it will not be shown again.'
}

Behavior:

  • Atomically creates new token and revokes old one
  • Can update metadata (name, expiration, scopes) in one operation
  • Old token ID cannot be used again
  • Both operations are audit logged

🔒 Scopes & Permissions

Scope Vocabulary

Scopes control what operations a token can perform. Scopes are hierarchical and role-aware.

// Read permissions
'projects:read'      // Read projects
'tests:read'         // Read test results and history
'costs:read'         // Read cost metrics

// Write permissions
'tests:run'          // Execute tests
'tests:write'        // Modify test configurations
'projects:write'     // Create/modify projects

// Admin permissions
'org:read'           // Read organization metadata
'org:write'          // Modify organization settings
'team:manage'        // Manage team members
'billing:read'       // Read billing information

// Full access
'*'                  // All scopes (owner-only)

Role-Based Scopes

Tokens inherit maximum scopes based on the creator's role:

// OWNER - Full access
['*']

// ADMIN - Broad access (excludes billing)
['projects:read', 'projects:write', 'tests:read', 'tests:run', 'tests:write',
 'costs:read', 'org:read', 'org:write', 'team:manage']

// MEMBER - Standard access
['projects:read', 'tests:read', 'tests:run']

// VIEWER - Read-only access
['projects:read', 'tests:read', 'costs:read']

Scope Validation

// Check if token has required scope
function hasScope(tokenScopes: string[], required: string): boolean {
  return tokenScopes.includes(required) || tokenScopes.includes('*')
}

// Validate scope is real
function isValidScope(scope: string): boolean {
  // Returns true only for defined scopes
  return ['projects:read', 'tests:run', '*', /* ... */].includes(scope)
}

Enforcement Examples

// ✅ ADMIN can create token with tests:run
const token = await trpc.tokens.createToken.mutate({
  organizationId: 'org_123',
  scopes: ['tests:run']  // Admin has this scope
})

// ❌ MEMBER cannot create token with team:manage
try {
  const token = await trpc.tokens.createToken.mutate({
    organizationId: 'org_123',
    scopes: ['team:manage']  // Member doesn't have this scope
  })
} catch (e) {
  // TRPCError: FORBIDDEN - "Your role (member) does not permit scope: team:manage"
}

📊 Rate Limiting

Tokens operations are rate-limited per organization:

// TOKEN_CREATE: 10 tokens per minute per org
// TOKEN_ROTATE: 20 rotations per minute per org
// TOKEN_LIST: 100 list requests per minute per org
// TOKEN_REVOKE: 50 revokes per minute per org

Rate limit headers in responses:

X-RateLimit-Limit: 10
X-RateLimit-Remaining: 8
X-RateLimit-Reset: 2024-01-15T10:31:00Z

Exceeding limits:

{
  code: 'TOO_MANY_REQUESTS',
  message: 'Rate limit exceeded. Try again after 2024-01-15T10:31:00Z'
}

🔌 Using API Tokens

Authentication Header

# Add token to Authorization header
curl -H "Authorization: Bearer izri_..." \
  https://api.example.com/trpc/projects.list

tRPC Client Configuration

import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'

const apiKey = process.env.IZRI_API_KEY
const orgId = process.env.IZRI_ORG_ID

const client = createTRPCProxyClient({
  links: [
    httpBatchLink({
      url: 'https://api.example.com/trpc',
      headers: {
        Authorization: `Bearer ${apiKey}`,
        'X-Organization-Id': orgId
      }
    })
  ]
})

// Use client
const projects = await client.projects.list.query()

Direct HTTP Requests

const response = await fetch('https://api.example.com/trpc/projects.list', {
  method: 'GET',
  headers: {
    'Authorization': `Bearer ${apiKey}`,
    'X-Organization-Id': orgId
  }
})

🔄 CI/CD Integration Examples

GitHub Actions

name: Run Tests
on: [push]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Set up environment
        run: |
          echo "IZRI_API_KEY=${{ secrets.IZRI_API_KEY }}" >> $GITHUB_ENV
          echo "IZRI_ORG_ID=${{ secrets.IZRI_ORG_ID }}" >> $GITHUB_ENV
      
      - name: Run Izri Tests
        run: |
          npm install @izri/client
          node run-tests.js

run-tests.js:

import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'

const client = createTRPCProxyClient({
  links: [
    httpBatchLink({
      url: 'https://api.example.com/trpc',
      headers: {
        'Authorization': `Bearer ${process.env.IZRI_API_KEY}`,
        'X-Organization-Id': process.env.IZRI_ORG_ID
      }
    })
  ]
})

// List projects
const projects = await client.projects.list.query()

// Run tests
for (const project of projects) {
  const result = await client.tests.run.mutate({
    projectId: project.id
  })
  console.log(`Project ${project.name}: ${result.status}`)
}

GitLab CI

stages:
  - test

run_tests:
  stage: test
  image: node:18
  script:
    - npm install
    - IZRI_API_KEY=$IZRI_API_KEY IZRI_ORG_ID=$IZRI_ORG_ID npm run test:remote

Jenkins Pipeline

pipeline {
  agent any
  
  environment {
    IZRI_API_KEY = credentials('izri-api-key')
    IZRI_ORG_ID = credentials('izri-org-id')
  }
  
  stages {
    stage('Test') {
      steps {
        sh '''
          npm install
          npm run test:remote
        '''
      }
    }
  }
}

🎨 Frontend Management

Create Token Dialog

Users can create tokens from the organization's API Tokens page:

/organizations/{slug}/api-tokens

Features:

  • Choose token name
  • Select scopes (limited to user's role)
  • Set expiration date (optional)
  • See raw token exactly once (with copy button)
  • Automatic logout after token creation (for security)

Token List

View all active tokens for the organization:

- Token name
- Scopes (shown as badges)
- Last used timestamp
- Expiration date
- Creation date
- Revoke button
- Rotate button

🔒 Security Best Practices

1. Use Minimum Scopes

// ❌ Too broad
const token = await trpc.tokens.createToken.mutate({
  scopes: ['*']  // Full access
})

// ✅ Specific permissions
const token = await trpc.tokens.createToken.mutate({
  scopes: ['projects:read', 'tests:run']  // Just what's needed
})

2. Rotate Regularly

// Create short-lived tokens
const token = await trpc.tokens.createToken.mutate({
  name: 'Temporary CI Token',
  expiresIn: 7 * 24 * 60 * 60  // 7 days
})

// Rotate before expiration
const newToken = await trpc.tokens.rotateToken.mutate({
  tokenId: token.id,
  expiresIn: 7 * 24 * 60 * 60
})

3. Monitor Usage

// Check last used timestamp
const tokens = await trpc.tokens.listTokens.query({ organizationId })

for (const token of tokens) {
  if (!token.lastUsedAt) {
    console.log(`Token "${token.name}" has never been used`)
  }
  
  const daysUnused = (Date.now() - token.lastUsedAt) / (1000 * 60 * 60 * 24)
  if (daysUnused > 30) {
    console.log(`Token "${token.name}" unused for ${daysUnused} days`)
  }
}

4. Revoke Compromised Tokens

// Immediately revoke if token is leaked
await trpc.tokens.revokeToken.mutate({
  organizationId: 'org_123',
  tokenId: 'token_abc123'
})

// Create new token
const newToken = await trpc.tokens.createToken.mutate({
  organizationId: 'org_123',
  name: 'Replacement Token',
  scopes: ['projects:read', 'tests:run']
})

5. Secure Storage

Environment Variables:

# .env.local (NOT committed)
IZRI_API_KEY=izri_...
IZRI_ORG_ID=org_...

CI/CD Secrets:

# GitHub Actions secrets
secrets.IZRI_API_KEY
secrets.IZRI_ORG_ID

Never:

  • ❌ Commit tokens to repository
  • ❌ Log tokens in debug output
  • ❌ Share tokens via email/chat
  • ❌ Use one token for multiple purposes
  • ❌ Grant tokens unnecessary scopes

🔄 Migration Guide (v1 → v2)

What Changed

  • Tokens are now org-scoped (not user-scoped)
  • Create endpoint location: Same tRPC path, different input
  • New required field: organizationId
  • Scope names: Unchanged, but validation is stricter
  • Breaking: Old user-scoped tokens no longer work

Migration Steps

  1. Create new org-scoped tokens:

    // v1 (deprecated)
    const token = await trpc.apiKeys.create.mutate({
      name: 'My Token',
      scopes: ['projects:read']
    })
    
    // v2 (new)
    const token = await trpc.tokens.createToken.mutate({
      organizationId: 'org_123',
      name: 'My Token',
      scopes: ['projects:read']
    })
    
  2. Update environment variables:

    # Add org ID
    IZRI_ORG_ID=org_123
    
  3. Update API calls:

    // Add org ID to headers
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'X-Organization-Id': orgId
    }
    
  4. Revoke old tokens (if still in system):

    // List to find old tokens
    const allTokens = await trpc.tokens.listTokens.query({
      organizationId
    })
    
    // Revoke if needed
    for (const token of allTokens) {
      await trpc.tokens.revokeToken.mutate({
        organizationId,
        tokenId: token.id
      })
    }
    

📋 Testing

Comprehensive tests cover:

describe('API Token System', () => {
  // Organization scoping
  it('isolates tokens across organizations', ...)
  it('prevents cross-org access', ...)
  
  // Scope enforcement
  it('validates role-based scopes', ...)
  it('prevents invalid scope assignment', ...)
  
  // Rate limiting
  it('enforces per-organization limits', ...)
  it('provides reset times', ...)
  
  // Audit logging
  it('logs token creation', ...)
  it('logs token revocation', ...)
  it('logs token rotation', ...)
  
  // Integration
  it('creates/lists/revokes/rotates tokens', ...)
  it('works in CI/CD environments', ...)
})

Run tests:

npm run test -- tokens.test.ts

🔗 Related Documentation

⚠️ Breaking Changes Summary

Feature v1 (Legacy) v2 (New) Impact
Scoping User-scoped Org-scoped Must provide organizationId
Route apiKeys.* tokens.* Update tRPC call paths
Validation Loose Strict role-based May be rejected if scopes exceed role
Rate Limits Per-user Per-org Higher limits but org-wide
Audit Logging Limited Comprehensive All operations logged with context

Last updated: 2024-01-15
Next: Webhooks

Reading this with an agent? /docs/features/api-keys.md serves the raw markdown.

All docs