Guide/guides

API Tokens Guide

Guren provides a secure API token system for authenticating API requests. Tokens are hashed before storage, support abilities (scopes), and can have expiration times.

API Tokens Guide

Guren provides a secure API token system for authenticating API requests. Tokens are hashed before storage, support abilities (scopes), and can have expiration times.

Core Concepts

  • ApiToken – Token data stored in the database (hashed, never stores plain text).
  • ApiTokenStore – Interface for token storage (memory or database).
  • Bearer Token Middleware – Authenticates requests using Authorization headers.
  • Abilities – Scopes that define what actions a token can perform.

Basic Usage

Creating Tokens

import { createApiToken, MemoryApiTokenStore } from '@guren/core'

const store = new MemoryApiTokenStore() // Use database in production

// Create a token for a user
const { plainTextToken, token } = await createApiToken(store, {
  name: 'Mobile App Token',
  userId: user.id,
  abilities: ['posts:read', 'posts:write'],
  expiresIn: 30 * 24 * 60 * 60 * 1000, // 30 days
})

// Return plainTextToken to the user - this is the ONLY time it's available!
return ctx.json({ token: plainTextToken })

Token Format

Tokens are returned in the format {id}|{token}:

abc123def456...|xyz789ghi012...

The token part is hashed before storage – the plain text token can never be recovered.

Verifying Tokens

import { verifyApiToken } from '@guren/core'

const result = await verifyApiToken(plainTextToken, store)

if (!result) {
  return ctx.json({ error: 'Invalid token' }, 401)
}

console.log(result.userId)     // User ID
console.log(result.abilities)  // ['posts:read', 'posts:write']
console.log(result.token)      // Token metadata (without plain text)

Token Abilities

Checking Abilities

import { tokenCan, tokenCanAll, tokenCanAny } from '@guren/core'

const token = { abilities: ['posts:read', 'posts:write'] }

// Check single ability
tokenCan(token, 'posts:read')    // true
tokenCan(token, 'posts:delete')  // false

// Check all abilities
tokenCanAll(token, ['posts:read', 'posts:write'])   // true
tokenCanAll(token, ['posts:read', 'posts:delete'])  // false

// Check any ability
tokenCanAny(token, ['posts:read', 'posts:delete'])  // true
tokenCanAny(token, ['users:read', 'users:write'])   // false

Wildcard Ability

Use * to grant all abilities:

const { plainTextToken } = await createApiToken(store, {
  name: 'Admin Token',
  userId: user.id,
  abilities: ['*'], // Can do anything
})

tokenCan({ abilities: ['*'] }, 'anything')  // true

Bearer Token Middleware

Basic Setup

import { createBearerTokenMiddleware } from '@guren/core'

// Protect all API routes
app.use('/api/*', createBearerTokenMiddleware({ store }))

With Ability Requirements

import { Router } from '@guren/core'

// Require specific abilities for routes
export function registerApiRoutes(router: Router): void {
  router.delete('/api/posts/:id', [PostController, 'destroy']).middleware(
    createBearerTokenMiddleware({
      store,
      abilities: ['posts:delete'],
    }),
  )
}

With User Loading

app.use('/api/*', createBearerTokenMiddleware({
  store,
  loadUser: async (userId) => {
    return User.find(userId)
  },
}))

// User is now available in the context
router.get('/api/me', (ctx) => {
  const user = ctx.get('guren:user')
  return ctx.json(user)
})

Custom Error Handlers

app.use('/api/*', createBearerTokenMiddleware({
  store,
  onUnauthorized: (ctx) => {
    return ctx.json({ error: 'Please provide a valid API token' }, 401)
  },
  onForbidden: (ctx, requiredAbilities) => {
    return ctx.json({
      error: 'Insufficient permissions',
      required: requiredAbilities,
    }, 403)
  },
}))

Accessing Token in Routes

import { getApiToken } from '@guren/core'

router.get('/api/token-info', (ctx) => {
  const tokenInfo = getApiToken(ctx)

  if (!tokenInfo) {
    return ctx.json({ error: 'Not authenticated' }, 401)
  }

  return ctx.json({
    userId: tokenInfo.userId,
    tokenName: tokenInfo.token.name,
    abilities: tokenInfo.abilities,
    lastUsedAt: tokenInfo.token.lastUsedAt,
  })
})

Token Management

Listing User Tokens

import { getUserApiTokens } from '@guren/core'

router.get('/api/tokens', async (ctx) => {
  const user = ctx.get('guren:user')
  const tokens = await getUserApiTokens(user.id, store)

  return ctx.json({
    tokens: tokens.map(t => ({
      id: t.id,
      name: t.name,
      abilities: t.abilities,
      lastUsedAt: t.lastUsedAt,
      createdAt: t.createdAt,
      expiresAt: t.expiresAt,
    })),
  })
})

Revoking Tokens

import { revokeApiToken, revokeAllApiTokens } from '@guren/core'

// Revoke a specific token
router.delete('/api/tokens/:id', async (ctx) => {
  const tokenId = ctx.req.param('id')
  await revokeApiToken(tokenId, store)
  return ctx.json({ message: 'Token revoked' })
})

// Revoke all tokens (e.g., on password change)
router.post('/api/tokens/revoke-all', async (ctx) => {
  const user = ctx.get('guren:user')
  await revokeAllApiTokens(user.id, store)
  return ctx.json({ message: 'All tokens revoked' })
})

Database Storage

Built-in DatabaseApiTokenStore

For production, use the built-in DatabaseApiTokenStore. Pass it the Drizzle table for your api_tokens schema — no custom store code needed:

import { DatabaseApiTokenStore } from '@guren/core'
import { apiTokens } from '@/db/schema'

const store = new DatabaseApiTokenStore(apiTokens)

// Works with every token helper
const { plainTextToken } = await createApiToken(store, {
  name: 'Mobile App Token',
  userId: user.id,
})

The store uses the app's configured ORM connection (the standard DatabaseProvider setup), so it needs no extra wiring. Expired tokens are already rejected by verifyApiToken; call store.deleteExpired() from a scheduled job to prune them from the table.

Database Schema

Column property names must match the ApiToken fields:

// db/schema.ts
import { pgTable, text, timestamp, jsonb } from 'drizzle-orm/pg-core'

export const apiTokens = pgTable('api_tokens', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  hashedToken: text('hashed_token').notNull().unique(),
  userId: text('user_id').notNull(),
  abilities: jsonb('abilities').$type<string[]>().notNull(),
  lastUsedAt: timestamp('last_used_at'),
  expiresAt: timestamp('expires_at'),
  createdAt: timestamp('created_at').notNull().defaultNow(),
})

If your abilities column is a plain text column holding a JSON string instead of jsonb, pass { abilitiesMode: 'text' }:

const store = new DatabaseApiTokenStore(apiTokens, { abilitiesMode: 'text' })

Custom Stores

Any object implementing the ApiTokenStore interface works — implement it yourself when tokens live in an external system:

import type { ApiTokenStore, ApiToken } from '@guren/core'

export class ExternalApiTokenStore implements ApiTokenStore {
  async store(token: ApiToken): Promise<void> { /* ... */ }
  async findByHashedToken(hashedToken: string): Promise<ApiToken | null> { /* ... */ }
  async findByUserId(userId: string | number): Promise<ApiToken[]> { /* ... */ }
  async delete(id: string): Promise<void> { /* ... */ }
  async deleteForUser(userId: string | number): Promise<void> { /* ... */ }
  async updateLastUsed(id: string, timestamp: Date): Promise<void> { /* ... */ }
}

Configuration Options

Token Creation Options

interface CreateApiTokenOptions {
  name: string                // Human-readable token name
  userId: string | number     // Owner user ID
  abilities?: string[]        // Token scopes (default: ['*'])
  expiresIn?: number | null   // Milliseconds until expiration
  tokenLength?: number        // Token bytes (default: 32)
}

Middleware Options

interface BearerTokenMiddlewareOptions {
  store: ApiTokenStore                                     // Token storage
  loadUser?: (userId: string | number) => Promise<unknown> // User loader
  abilities?: string[]                                     // Required abilities
  onUnauthorized?: (ctx: Context) => Response             // 401 handler
  onForbidden?: (ctx: Context, required: string[]) => Response  // 403 handler
  headerName?: string                                      // Header name (default: 'Authorization')
  updateLastUsed?: boolean                                 // Track usage (default: true)
}

Testing

import { describe, test, expect, beforeEach } from 'bun:test'
import {
  createApiToken,
  verifyApiToken,
  MemoryApiTokenStore,
  createBearerTokenMiddleware,
} from '@guren/core'
import { Hono } from 'hono'

describe('API Tokens', () => {
  let store: MemoryApiTokenStore

  beforeEach(() => {
    store = new MemoryApiTokenStore()
  })

  test('creates and verifies token', async () => {
    const { plainTextToken, token } = await createApiToken(store, {
      name: 'Test Token',
      userId: 1,
      abilities: ['read'],
    })

    expect(plainTextToken).toMatch(/^[a-f0-9]+\|[a-f0-9]+$/)

    const result = await verifyApiToken(plainTextToken, store)
    expect(result?.userId).toBe(1)
    expect(result?.abilities).toEqual(['read'])
  })

  test('rejects expired token', async () => {
    const { plainTextToken } = await createApiToken(store, {
      name: 'Test Token',
      userId: 1,
      expiresIn: -1000, // Already expired
    })

    const result = await verifyApiToken(plainTextToken, store)
    expect(result).toBeNull()
  })

  test('middleware authenticates request', async () => {
    const { plainTextToken } = await createApiToken(store, {
      name: 'Test Token',
      userId: 1,
    })

    const app = new Hono()
    app.use('*', createBearerTokenMiddleware({ store }))
    app.get('/', (c) => c.text('OK'))

    const res = await app.request('/', {
      headers: { Authorization: `Bearer ${plainTextToken}` },
    })

    expect(res.status).toBe(200)
  })

  test('middleware checks abilities', async () => {
    const { plainTextToken } = await createApiToken(store, {
      name: 'Test Token',
      userId: 1,
      abilities: ['read'],
    })

    const app = new Hono()
    app.use('*', createBearerTokenMiddleware({
      store,
      abilities: ['write'],
    }))
    app.get('/', (c) => c.text('OK'))

    const res = await app.request('/', {
      headers: { Authorization: `Bearer ${plainTextToken}` },
    })

    expect(res.status).toBe(403)
  })
})

Best Practices

  1. Never store plain tokens: Only the hashed token is stored. The plain text is shown once at creation.

  2. Use specific abilities: Prefer ['posts:read', 'posts:write'] over ['*'] for better security.

  3. Set expiration times: Tokens should expire for security. 30-90 days is common.

  4. Revoke on password change: When a user changes their password, revoke all their tokens.

  5. Use database storage in production: MemoryApiTokenStore is only for testing.

  6. Track last used: The lastUsedAt field helps identify unused tokens.

  7. Name tokens meaningfully: Use names like "Mobile App" or "CI/CD Pipeline" for easy identification.

  8. Implement token rotation: Allow users to regenerate tokens periodically.