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
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'] })Update environment variables:
# Add org ID IZRI_ORG_ID=org_123Update API calls:
// Add org ID to headers headers: { 'Authorization': `Bearer ${apiKey}`, 'X-Organization-Id': orgId }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
- Authentication: Backend Auth Overview
- API Reference: Full API Reference
- Webhooks: Webhook Authentication
⚠️ 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 →