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 identifierfresh: 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
- Use Strong Secret Keys: Generate cryptographically secure secret keys
- Keep Expiry Times Short: Short-lived access tokens reduce security risk
- Enable Database Revocation: Always use database revocation for production
- Regular Cleanup: Schedule regular cleanup of expired tokens
- Check Token Freshness: Require fresh tokens for sensitive operations
- Use HTTPS: Always transmit tokens over secure connections
- Transaction Management: Always use transactions for revocation operations
- 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
AutoCreateis set totruein 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, andsubcolumns - Run cleanup more frequently
- Consider partitioning the table by date
See Also
Links
- GoDoc - API documentation
- Source Code - Repository
- Issue Tracker - Report bugs