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)
})
The loaded user becomes the request's principal, so this.auth.user() in a
controller, requireAuthenticated(), and the Gate (policies,
this.authorize(), authorizeMiddleware()) all answer with it. The principal
is recorded on the request rather than on the auth context, so it is read
whether the middleware is mounted before or after boot().
auth.logout() on such a request revokes the token it presented and leaves a
co-present session alone. A loadUser that returns null leaves the request
unauthenticated: the token verified, so that request belongs to the token, and
a logged-in session does not stand in for the user it names. A request the
middleware never ran for keeps its session user. Where useTokens({ provider })
is configured, the loaded user is sanitized through that provider, so password
hashes and the model's hidden fields never leave the auth layer.
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 '@guren/orm/drizzle/pg'
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', { withTimezone: true }),
expiresAt: timestamp('expires_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
On SQLite, declare the timestamp columns as text holding ISO strings (the shape create-guren-app gives users.created_at) or as integer(..., { mode: 'timestamp_ms' }). The store writes each column in the form it declares: a Date for a drizzle timestamp-mode column, an ISO string for a text column, epoch milliseconds for an integer column with no mode.
// db/schema.ts
import { sqliteTable, text } from '@guren/orm/drizzle/sqlite'
export const apiTokens = sqliteTable('api_tokens', {
id: text('id').primaryKey(),
name: text('name').notNull(),
hashedToken: text('hashed_token').notNull().unique(),
userId: text('user_id').notNull(),
abilities: text('abilities', { mode: 'json' }).$type<string[]>().notNull(),
lastUsedAt: text('last_used_at'),
expiresAt: text('expires_at'),
createdAt: text('created_at').notNull(),
})
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
Never store plain tokens: Only the hashed token is stored. The plain text is shown once at creation.
Use specific abilities: Prefer
['posts:read', 'posts:write']over['*']for better security.Set expiration times: Tokens should expire for security. 30-90 days is common.
Revoke on password change: When a user changes their password, revoke all their tokens.
Use database storage in production:
MemoryApiTokenStoreis only for testing.Track last used: The
lastUsedAtfield helps identify unused tokens.Name tokens meaningfully: Use names like "Mobile App" or "CI/CD Pipeline" for easy identification.
Implement token rotation: Allow users to regenerate tokens periodically.