Clone
7
JWT
Haelnorr edited this page 2026-01-13 13:43:24 +11:00

JWT Package - v0.10.1

The jwt package provides comprehensive JWT (JSON Web Token) generation and validation with database-backed token revocation support.

Features

  • Access and Refresh Tokens: Separate tokens for authentication and token renewal
  • Token Revocation: Database-backed blacklist for revoked tokens
  • Multi-Database Support: Works with PostgreSQL, MySQL, SQLite, and MariaDB
  • ORM Compatibility: Compatible with database/sql, GORM, and Bun
  • Automatic Table Management: Creates and manages blacklist table automatically
  • Native Cleanup: Database-specific automatic cleanup (PostgreSQL functions, MySQL events)
  • Token Freshness: Track token freshness for sensitive operations
  • Remember Me: Support for session vs persistent tokens

Installation

go get git.haelnorr.com/h/golib/jwt

Quick Start

Basic Setup (with Database)

package main

import (
    "context"
    "database/sql"
    "git.haelnorr.com/h/golib/jwt"
    _ "github.com/lib/pq"
)

func main() {
    // Open database connection
    db, err := sql.Open("postgres", "postgres://user:pass@localhost/dbname?sslmode=disable")
    if err != nil {
        panic(err)
    }
    defer db.Close()

    // Create a transaction getter function
    txGetter := func(ctx context.Context) (jwt.DBTransaction, error) {
        return db.Begin()
    }

    // Create token generator
    gen, err := jwt.CreateGenerator(jwt.GeneratorConfig{
        AccessExpireAfter:  15,   // Access tokens expire in 15 minutes
        RefreshExpireAfter: 1440, // Refresh tokens expire in 24 hours (1440 minutes)
        FreshExpireAfter:   5,    // Tokens stay fresh for 5 minutes
        TrustedHost:        "example.com",
        SecretKey:          "your-secret-key-here",
        DB:                 db,
        DBType: jwt.DatabaseType{
            Type:    jwt.DatabasePostgreSQL,
            Version: "15",
        },
        TableConfig: jwt.DefaultTableConfig(),
    }, txGetter)
    if err != nil {
        panic(err)
    }

    // Generate tokens
    userID := 42
    accessToken, accessExp, err := gen.NewAccess(userID, true, false)
    refreshToken, refreshExp, err := gen.NewRefresh(userID, false)
}

Without Database (No Revocation)

gen, err := jwt.CreateGenerator(jwt.GeneratorConfig{
    AccessExpireAfter:  15,
    RefreshExpireAfter: 1440,
    FreshExpireAfter:   5,
    TrustedHost:        "example.com",
    SecretKey:          "your-secret-key-here",
    DB:                 nil, // No database = no revocation
}, nil) // nil transaction getter since no DB

Configuration

GeneratorConfig

type GeneratorConfig struct {
    // Required fields
    AccessExpireAfter  int64  // Access token expiry in minutes
    RefreshExpireAfter int64  // Refresh token expiry in minutes
    FreshExpireAfter   int64  // Token freshness duration in minutes
    TrustedHost        string // Trusted hostname for token issuer
    SecretKey          string // Secret key for token signing

    // Optional fields (for database support)
    DB          *sql.DB       // Database connection (nil to disable revocation)
    DBType      DatabaseType  // Database type and version
    TableConfig TableConfig   // Table configuration
}

DatabaseType

Supported database types with their constants:

jwt.DatabasePostgreSQL  // "postgres"
jwt.DatabaseMySQL       // "mysql"
jwt.DatabaseSQLite      // "sqlite"
jwt.DatabaseMariaDB     // "mariadb"

Example:

DBType: jwt.DatabaseType{
    Type:    jwt.DatabasePostgreSQL,
    Version: "15.3",
}

TableConfig

type TableConfig struct {
    TableName         string // Blacklist table name (default: "jwtblacklist")
    AutoCreate        bool   // Auto-create table if missing (default: true)
    EnableAutoCleanup bool   // Enable database-native cleanup (default: true)
    CleanupInterval   int    // Cleanup interval in hours (default: 24)
}

Get default configuration:

config := jwt.DefaultTableConfig()
config.TableName = "my_custom_blacklist" // Customize as needed

Token Generation

Access Tokens

// NewAccess(subject int, fresh bool, rememberMe bool)
token, expiry, err := gen.NewAccess(42, true, false)

Parameters:

  • subject: User ID or subject identifier
  • fresh: Whether token should be marked as fresh (for sensitive operations)
  • rememberMe: If true, token is persistent; if false, session-only

Refresh Tokens

// NewRefresh(subject int, rememberMe bool)
token, expiry, err := gen.NewRefresh(42, true)

Token Validation

Tokens must be validated within a transaction context. The package accepts any transaction type that implements the DBTransaction interface (which *sql.Tx does automatically):

Using Standard Library

// Begin transaction
tx, err := db.Begin()
if err != nil {
    return err
}
defer tx.Rollback()

// Validate access token
token, err := gen.ValidateAccess(tx, tokenString)
if err != nil {
    return err // Token invalid or revoked
}

// Validate refresh token
refreshToken, err := gen.ValidateRefresh(tx, tokenString)
if err != nil {
    return err // Token invalid or revoked
}

// Commit transaction
tx.Commit()

Using ORM Transactions

You can use ORM transactions directly since they implement the DBTransaction interface:

// GORM transaction
tx := gormDB.Begin()
token, err := gen.ValidateAccess(tx.Statement.ConnPool, tokenString)
tx.Commit()

// Bun transaction
tx, _ := bunDB.BeginTx(context.Background(), nil)
token, err := gen.ValidateAccess(tx, tokenString)
tx.Commit()

Token Properties

After validation, you can access token claims:

token, _ := gen.ValidateAccess(exec, tokenString)

userID := token.SUB           // Subject (user ID)
issuer := token.ISS           // Issuer (trusted host)
expiry := token.EXP           // Expiration timestamp
issuedAt := token.IAT         // Issued at timestamp
freshUntil := token.Fresh     // Fresh until timestamp
tokenID := token.JTI          // Unique token identifier
scope := token.Scope          // "access" or "refresh"
ttl := token.TTL              // "session" or "exp"

Token Revocation

The revocation methods accept any transaction implementing the DBTransaction interface.

Revoking a Token

// Using standard library
tx, _ := db.Begin()
defer tx.Rollback()

// Validate token first
token, err := gen.ValidateAccess(tx, tokenString)
if err != nil {
    return err
}

// Revoke the token
err = token.Revoke(tx)
if err != nil {
    return err
}

tx.Commit()

// Or using GORM
tx := gormDB.Begin()
token, _ := gen.ValidateAccess(tx.Statement.ConnPool, tokenString)
token.Revoke(tx.Statement.ConnPool)
tx.Commit()

Checking Revocation Status

isValid, err := token.CheckNotRevoked(tx)
if !isValid {
    // Token has been revoked
}

Token Freshness

Tokens can be marked as "fresh" for sensitive operations:

// Generate fresh token
accessToken, _, _ := gen.NewAccess(userID, true, false) // fresh=true

// Check freshness
tx, _ := db.Begin()
defer tx.Rollback()

token, _ := gen.ValidateAccess(tx, accessToken)
currentTime := time.Now().Unix()
if currentTime > token.Fresh {
    // Token is stale, require re-authentication
    return errors.New("token is not fresh")
}

tx.Commit()

Cleanup

Manual Cleanup

Remove expired tokens from the blacklist:

import "context"

err := gen.Cleanup(context.Background())

Automatic Cleanup

PostgreSQL

The package creates a cleanup function that you can schedule:

-- Call manually
SELECT cleanup_jwtblacklist();

-- Or schedule with pg_cron extension
SELECT cron.schedule('cleanup-jwt', '0 * * * *', 'SELECT cleanup_jwtblacklist()');

MySQL/MariaDB

An automatic event is created (requires event_scheduler to be enabled):

-- Check if event scheduler is enabled
SHOW VARIABLES LIKE 'event_scheduler';

-- Enable if needed
SET GLOBAL event_scheduler = ON;

-- View the created event
SHOW EVENTS LIKE 'cleanup_jwtblacklist_event';

SQLite

SQLite doesn't support automatic cleanup. Use manual cleanup:

// Run periodically (e.g., via cron or background goroutine)
ticker := time.NewTicker(24 * time.Hour)
go func() {
    for range ticker.C {
        gen.Cleanup(context.Background())
    }
}()

Database Schema

The blacklist table is created automatically with the following structure:

PostgreSQL

CREATE TABLE IF NOT EXISTS jwtblacklist (
    jti UUID PRIMARY KEY,
    exp BIGINT NOT NULL,
    sub INTEGER NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_jwtblacklist_exp ON jwtblacklist(exp);
CREATE INDEX IF NOT EXISTS idx_jwtblacklist_sub ON jwtblacklist(sub);

MySQL/MariaDB

CREATE TABLE IF NOT EXISTS jwtblacklist (
    jti CHAR(36) PRIMARY KEY,
    exp BIGINT NOT NULL,
    sub INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_exp (exp),
    INDEX idx_sub (sub)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

SQLite

CREATE TABLE IF NOT EXISTS jwtblacklist (
    jti TEXT PRIMARY KEY,
    exp INTEGER NOT NULL,
    sub INTEGER NOT NULL,
    created_at INTEGER DEFAULT (strftime('%s', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_jwtblacklist_exp ON jwtblacklist(exp);
CREATE INDEX IF NOT EXISTS idx_jwtblacklist_sub ON jwtblacklist(sub);

Using with ORMs

GORM

GORM transactions can be used directly with the JWT package:

import (
    "context"
    "gorm.io/gorm"
    "gorm.io/driver/postgres"
)

// Get underlying *sql.DB from GORM
gormDB, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{})
sqlDB, _ := gormDB.DB()

// Create a transaction getter function
txGetter := func(ctx context.Context) (jwt.DBTransaction, error) {
    return sqlDB.Begin()
}

// Create generator with sql.DB
gen, _ := jwt.CreateGenerator(jwt.GeneratorConfig{
    AccessExpireAfter:  15,
    RefreshExpireAfter: 1440,
    FreshExpireAfter:   5,
    TrustedHost:        "example.com",
    SecretKey:          "your-secret-key",
    DB:                 sqlDB,
    DBType: jwt.DatabaseType{
        Type:    jwt.DatabasePostgreSQL,
        Version: "15",
    },
    TableConfig: jwt.DefaultTableConfig(),
}, txGetter)

// Option 1: Use GORM transactions directly
tx := gormDB.Begin()
token, _ := gen.ValidateAccess(tx.Statement.ConnPool, tokenString)
tx.Commit()

// Option 2: Use standard sql.DB transactions
sqlTx, _ := sqlDB.Begin()
defer sqlTx.Rollback()
token, _ := gen.ValidateAccess(sqlTx, tokenString)
sqlTx.Commit()

Bun

Bun transactions can be used directly with the JWT package:

import (
    "context"
    "database/sql"
    "github.com/uptrace/bun"
    "github.com/uptrace/bun/dialect/pgdialect"
    "github.com/uptrace/bun/driver/pgdriver"
)

// Create *sql.DB connection first
sqlDB := sql.Open(pgdriver.NewConnector(pgdriver.WithDSN(dsn)))

// Pass sql.DB to both Bun and JWT generator
bunDB := bun.NewDB(sqlDB, pgdialect.New())

// Create a transaction getter function
txGetter := func(ctx context.Context) (jwt.DBTransaction, error) {
    return sqlDB.Begin()
}

// Create generator with the same sql.DB
gen, _ := jwt.CreateGenerator(jwt.GeneratorConfig{
    AccessExpireAfter:  15,
    RefreshExpireAfter: 1440,
    FreshExpireAfter:   5,
    TrustedHost:        "example.com",
    SecretKey:          "your-secret-key",
    DB:                 sqlDB,
    DBType: jwt.DatabaseType{
        Type:    jwt.DatabasePostgreSQL,
        Version: "15",
    },
    TableConfig: jwt.DefaultTableConfig(),
}, txGetter)

// Option 1: Use Bun transactions directly (recommended)
ctx := context.Background()
tx, _ := bunDB.BeginTx(ctx, nil)
token, _ := gen.ValidateAccess(tx, tokenString)  // Bun's tx implements DBTransaction
tx.Commit()

// Option 2: Use standard sql.DB transactions
sqlTx, _ := sqlDB.Begin()
defer sqlTx.Rollback()
token, _ := gen.ValidateAccess(sqlTx, tokenString)
sqlTx.Commit()

Complete Example

package main

import (
    "context"
    "database/sql"
    "fmt"
    "time"

    "git.haelnorr.com/h/golib/jwt"
    _ "github.com/lib/pq"
)

func main() {
    // Setup database
    db, err := sql.Open("postgres", "postgres://user:pass@localhost/mydb?sslmode=disable")
    if err != nil {
        panic(err)
    }
    defer db.Close()

    // Create a transaction getter function
    txGetter := func(ctx context.Context) (jwt.DBTransaction, error) {
        return db.Begin()
    }

    // Create token generator
    gen, err := jwt.CreateGenerator(jwt.GeneratorConfig{
        AccessExpireAfter:  15,
        RefreshExpireAfter: 1440,
        FreshExpireAfter:   5,
        TrustedHost:        "example.com",
        SecretKey:          "super-secret-key",
        DB:                 db,
        DBType: jwt.DatabaseType{
            Type:    jwt.DatabasePostgreSQL,
            Version: "15",
        },
        TableConfig: jwt.DefaultTableConfig(),
    }, txGetter)
    if err != nil {
        panic(err)
    }

    // Generate tokens for user 42
    userID := 42
    accessToken, accessExp, err := gen.NewAccess(userID, true, false)
    if err != nil {
        panic(err)
    }
    refreshToken, refreshExp, err := gen.NewRefresh(userID, false)
    if err != nil {
        panic(err)
    }

    fmt.Printf("Access Token: %s (expires: %d)\n", accessToken, accessExp)
    fmt.Printf("Refresh Token: %s (expires: %d)\n", refreshToken, refreshExp)

    // Validate access token
    tx, _ := db.Begin()
    defer tx.Rollback()
    
    token, err := gen.ValidateAccess(tx, accessToken)
    if err != nil {
        panic(err)
    }
    
    fmt.Printf("Token valid for user: %d\n", token.SUB)
    
    // Revoke token
    err = token.Revoke(tx)
    if err != nil {
        panic(err)
    }
    
    tx.Commit()
    
    // Try to validate revoked token
    tx2, _ := db.Begin()
    defer tx2.Rollback()
    
    _, err = gen.ValidateAccess(tx2, accessToken)
    if err != nil {
        fmt.Println("Token is revoked:", err)
    }
    
    tx2.Commit()
    
    // Cleanup expired tokens
    err = gen.Cleanup(context.Background())
    if err != nil {
        panic(err)
    }
}

Best Practices

  1. Use Strong Secret Keys: Generate cryptographically secure secret keys
  2. Keep Expiry Times Short: Short-lived access tokens reduce security risk
  3. Enable Database Revocation: Always use database revocation for production
  4. Regular Cleanup: Schedule regular cleanup of expired tokens
  5. Check Token Freshness: Require fresh tokens for sensitive operations
  6. Use HTTPS: Always transmit tokens over secure connections
  7. Transaction Management: Always use transactions for revocation operations
  8. Monitor Blacklist Size: Keep an eye on blacklist table growth

Troubleshooting

Table Not Created

If the table isn't auto-created, check:

  • Database connection is valid
  • User has CREATE TABLE permissions
  • AutoCreate is set to true in TableConfig

Cleanup Not Working

PostgreSQL:

  • Ensure the cleanup function exists: SELECT routine_name FROM information_schema.routines WHERE routine_name LIKE 'cleanup_%';
  • Schedule with pg_cron or call manually

MySQL:

  • Check event scheduler: SHOW VARIABLES LIKE 'event_scheduler';
  • Enable if needed: SET GLOBAL event_scheduler = ON;
  • Check events: SHOW EVENTS;

Performance Issues

If blacklist queries are slow:

  • Ensure indexes exist on jti, exp, and sub columns
  • Run cleanup more frequently
  • Consider partitioning the table by date

See Also

  • HWSAuth - JWT authentication middleware for HWS
  • HWS - H Web Server framework