seal

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 23, 2026 License: MIT Imports: 17 Imported by: 0

README

Seal - JWT Authentication for Go

A lightweight, secure JWT authentication library with token rotation and multiple storage backends.

Features

  • JWT access tokens with configurable TTL
  • Single-use refresh tokens with automatic rotation
  • Token revocation (single or all sessions)
  • Optional Redis blocklist for immediate access token invalidation
  • HTTP middleware for route protection
  • Multiple storage backends: Redis, PostgreSQL, MySQL, SQLite

Install

go get github.com/codetesla51/seal

Quick Start

s := seal.NewSeal(&seal.SealConfig{
    SecretKey:       "your-secret-key-min-32-chars",
    AccessTokenTTL:  15 * time.Minute,
    RefreshTokenTTL: 7 * 24 * time.Hour,
    Store:           store,
})

// Login: issue new tokens
access, refresh, err := s.IssueTokens(ctx, "user-123")

// Refresh: old token is invalidated, new pair issued
access, newRefresh, err := s.RefreshTokens(ctx, refresh)

// Logout: revoke all refresh tokens + block access token (if blocklist configured)
s.Logout(ctx, userID, access)

How It Works

Seal uses two tokens:

Access Token - A short-lived JWT containing the user's ID. It is stateless (the server doesn't store it) and lives on the client. When it expires, the client uses the refresh token to get a new one.

Refresh Token - A long-lived random string that is hashed (SHA-256) and stored server-side. It is single-use: when you call RefreshTokens, the old refresh token is deleted from the store and a new pair is issued. This rotation prevents replay attacks — if a refresh token is stolen, it can only be used once before becoming invalid.

Storage Backends

import "github.com/redis/go-redis/v9"

rdb := redis.NewClient(&redis.Options{
    Addr:     "localhost:6379",
    Password: "",
    DB:       0,
})

store := seal.NewRedisStore(rdb)
PostgreSQL
import "github.com/jackc/pgx/v5/pgxpool"

pool, err := pgxpool.New(ctx, "postgres://user:pass@host/db")
if err != nil {
    log.Fatal(err)
}

store := seal.NewPostgresStore(pool)
MySQL
import (
    "database/sql"
    _ "github.com/go-sql-driver/mysql"
)

db, err := sql.Open("mysql", "user:pass@tcp(localhost:3306)/dbname")
if err != nil {
    log.Fatal(err)
}

store := seal.NewMySQLStore(db)
SQLite (for simple apps/dev)
import (
    "database/sql"
    _ "github.com/mattn/go-sqlite3"
)

db, err := sql.Open("sqlite3", "./seal.db")
if err != nil {
    log.Fatal(err)
}

store := seal.NewSQLiteStore(db)

HTTP Middleware

The middleware intercepts requests, extracts the access token from the Authorization: Bearer <token> header, validates it, and injects the user ID into the request context.

func main() {
    mux := http.NewServeMux()
    mux.Handle("/api/", s.Middleware()(http.HandlerFunc(handler)))
    http.ListenAndServe(":8080", mux)
}

func handler(w http.ResponseWriter, r *http.Request) {
    userID, ok := seal.UserIDFromContext(r.Context())
    if !ok {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }
    fmt.Fprintf(w, "Hello, %s!", userID)
}
Client Side

The client stores the access token and sends it in the Authorization header:

curl -H "Authorization: Bearer eyJhbGci..." https://api.example.com/protected

API Reference

NewSeal(config *SealConfig) *Seal

Creates a new Seal instance with your configuration.

s := seal.NewSeal(&seal.SealConfig{
    SecretKey:       "your-secret-key-min-32-chars",
    AccessTokenTTL:  15 * time.Minute,   // How long access tokens last
    RefreshTokenTTL: 7 * 24 * time.Hour, // How long refresh tokens last
    Store:           store,              // Your storage backend
    Blocklist:       blocklist,          // Optional: for immediate access token revocation
})

SecretKey - The secret used to sign JWT access tokens. Must be at least 32 random characters. Seal will log a warning at startup if it is too short.

AccessTokenTTL - How long until access tokens expire. Short is more secure. 15 minutes is a good default.

RefreshTokenTTL - How long until refresh tokens expire. This determines how long a user stays logged in without re-entering credentials. 7 days is typical.

Store - Where refresh tokens are stored. Required.

Blocklist - Optional Redis blocklist. If provided, revoked access tokens are immediately blocked. Without this, access tokens remain valid until they naturally expire.


IssueTokens(ctx, userID) (accessToken, refreshToken, err)

Call this when a user successfully logs in. Generates an access token (JWT) and a refresh token (random string stored hashed in your store).

access, refresh, err := s.IssueTokens(ctx, "user-123")
if err != nil {
    // handle error
}
// send both tokens to the client

RefreshTokens(ctx, rawRefreshToken) (accessToken, refreshToken, err)

Call this when the client's access token has expired. The client sends their refresh token and gets a new pair back. The old refresh token is deleted and a new one is issued (rotation).

access, newRefresh, err := s.RefreshTokens(ctx, refreshTokenFromClient)
if err != nil {
    if errors.Is(err, seal.ErrTokenExpired) {
        // session fully expired, user must log in again
    }
    if errors.Is(err, seal.ErrTokenNotFound) {
        // token doesn't exist — possible theft, force re-login
    }
    return
}
// send new tokens to client

ValidateAccessToken(tokenString) (userID, jti, err)

Manually validate an access token outside of middleware. Returns the user ID and the JWT ID (jti).

userID, jti, err := s.ValidateAccessToken(tokenString)
if err != nil {
    // token invalid or expired
}

The jti is a unique identifier per token. Use it with a blocklist to revoke specific tokens immediately.


RevokeToken(ctx, rawRefreshToken) error

Revoke a single refresh token. Use this to invalidate one specific session (e.g. "log out this device").

err := s.RevokeToken(ctx, refreshTokenFromClient)

RevokeAll(ctx, userID) error

Revoke all refresh tokens for a user. Use this to force re-login on all devices (e.g. password change, security breach).

err := s.RevokeAll(ctx, "user-123")

Logout(ctx, userID, rawAccessToken) error

Complete logout. Does two things:

  1. Revokes all refresh tokens for the user
  2. Adds the access token's jti to the blocklist (if configured)

Without a blocklist, only refresh tokens are revoked. The access token remains valid until it expires naturally.

err := s.Logout(ctx, userID, accessToken)

Middleware() func(http.Handler) http.Handler

Returns an HTTP middleware that validates the access token on every request and sets the user ID in the request context. Returns 401 if the token is missing, invalid, expired, or blocklisted.

http.Handle("/api/", s.Middleware()(protectedHandler))

UserIDFromContext(ctx) (string, bool)

Extract the user ID injected by the middleware from a request context.

func handler(w http.ResponseWriter, r *http.Request) {
    userID, ok := seal.UserIDFromContext(r.Context())
    if !ok {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }
    // use userID
}

Error Reference

Use errors.Is() when comparing Seal errors — they may be wrapped.

Error When Action
ErrTokenExpired Refresh token past expiration Redirect to login
ErrTokenNotFound Token not in store (used, revoked, or fake) Force re-login, possible theft
ErrTokenRevoked Token explicitly revoked Redirect to login
ErrTokenInvalid JWT signature mismatch or tampered Reject request
ErrUnauthorized No token or bad format in header Obtain valid token
ErrInvalidCredentials Wrong login credentials Show login error
if errors.Is(err, seal.ErrTokenNotFound) {
    // possible theft — force re-login and alert user
}

Database Schema

For SQL backends (PostgreSQL, MySQL, SQLite):

CREATE TABLE refresh_tokens (
    hash       VARCHAR(64)  PRIMARY KEY,
    user_id    VARCHAR(255) NOT NULL,
    expired_at TIMESTAMP    NOT NULL,
    created_at TIMESTAMP    NOT NULL
);

Blocklist (Optional)

Configure a Redis blocklist to make access token revocation immediate on logout. Without it, a logged-out access token stays valid until its TTL expires.

blocklist := seal.NewRedisBlocklist(redisClient)

s := seal.NewSeal(&seal.SealConfig{
    Store:     store,
    Blocklist: blocklist,
    // ...
})

When Logout is called, the access token's jti is stored in Redis with a TTL matching its remaining lifetime. Middleware rejects any request carrying a blocklisted jti.


Security Notes

  • Refresh tokens are hashed (SHA-256) before storage. The raw token is only ever known to the client.
  • Access tokens are stateless JWTs. Without a blocklist, they remain valid until expiry even after logout.
  • SecretKey must be at least 32 random characters. Seal logs a warning at startup if it is too short.
  • Always use HTTPS in production. Tokens travel in headers and can be intercepted over plain HTTP.
  • Store refresh tokens in httpOnly cookies on the frontend to protect against XSS theft.
  • Short access token TTL + Redis blocklist = strongest security posture.

License

MIT

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrInvalidCredentials = NewSealError("invalid_credentials", "Invalid username or password")
	ErrTokenExpired       = NewSealError("token_expired", "The token has expired")
	ErrTokenInvalid       = NewSealError("token_invalid", "The token is invalid")
	ErrInternalServer     = NewSealError("internal_server_error", "An internal server error occurred")
	ErrTokenNotFound      = NewSealError("token_not_found", "Refresh token not found")
	ErrUserNotFound       = NewSealError("user_not_found", "User not found")
	ErrUserExists         = NewSealError("user_exists", "User already exists")
	ErrTokenRevoked       = NewSealError("token_revoked", "Token has been revoked")
	ErrUnauthorized       = NewSealError("unauthorized", "Unauthorized")
)

Functions

func GenerateRefreshToken

func GenerateRefreshToken() (string, error)

func HashToken

func HashToken(raw string) string

func UserIDFromContext

func UserIDFromContext(ctx context.Context) (string, bool)

Types

type Blocklist

type Blocklist interface {
	Block(ctx context.Context, jti string, ttl time.Duration) error
	IsBlocked(ctx context.Context, jti string) (bool, error)
}

type Claims

type Claims struct {
	UserID string `json:"user_id"`
	jwt.RegisteredClaims
}

type MySQLStore

type MySQLStore struct {
	// contains filtered or unexported fields
}

func NewMySQLStore

func NewMySQLStore(db *sql.DB) *MySQLStore

func (*MySQLStore) DeleteAllRefreshTokens

func (s *MySQLStore) DeleteAllRefreshTokens(ctx context.Context, userId string) error

func (*MySQLStore) DeleteRefreshToken

func (s *MySQLStore) DeleteRefreshToken(ctx context.Context, hash string) error

func (*MySQLStore) GetRefreshToken

func (s *MySQLStore) GetRefreshToken(ctx context.Context, hash string) (*RefreshToken, error)

func (*MySQLStore) SaveRefreshToken

func (s *MySQLStore) SaveRefreshToken(ctx context.Context, token RefreshToken) error

type PostgresStore

type PostgresStore struct {
	// contains filtered or unexported fields
}

func NewPostgresStore

func NewPostgresStore(db *pgxpool.Pool) *PostgresStore

func (*PostgresStore) DeleteAllRefreshTokens

func (s *PostgresStore) DeleteAllRefreshTokens(ctx context.Context, userId string) error

func (*PostgresStore) DeleteRefreshToken

func (s *PostgresStore) DeleteRefreshToken(ctx context.Context, hash string) error

func (*PostgresStore) GetRefreshToken

func (s *PostgresStore) GetRefreshToken(ctx context.Context, hash string) (*RefreshToken, error)

func (*PostgresStore) SaveRefreshToken

func (s *PostgresStore) SaveRefreshToken(ctx context.Context, token RefreshToken) error

type RedisBlocklist

type RedisBlocklist struct {
	// contains filtered or unexported fields
}

func NewRedisBlocklist

func NewRedisBlocklist(client *redis.Client) *RedisBlocklist

func (*RedisBlocklist) Block

func (b *RedisBlocklist) Block(ctx context.Context, jti string, ttl time.Duration) error

func (*RedisBlocklist) IsBlocked

func (b *RedisBlocklist) IsBlocked(ctx context.Context, jti string) (bool, error)

type RedisStore

type RedisStore struct {
	// contains filtered or unexported fields
}

func NewRedisStore

func NewRedisStore(client *redis.Client) *RedisStore

func (*RedisStore) DeleteAllRefreshTokens

func (s *RedisStore) DeleteAllRefreshTokens(ctx context.Context, userId string) error

func (*RedisStore) DeleteRefreshToken

func (s *RedisStore) DeleteRefreshToken(ctx context.Context, hash string) error

func (*RedisStore) GetRefreshToken

func (s *RedisStore) GetRefreshToken(ctx context.Context, hash string) (*RefreshToken, error)

func (*RedisStore) SaveRefreshToken

func (s *RedisStore) SaveRefreshToken(ctx context.Context, token RefreshToken) error

type RefreshToken

type RefreshToken struct {
	Hash      string
	UserId    string
	ExpiredAt time.Time
	CreatedAt time.Time
}

type SQLiteStore

type SQLiteStore struct {
	// contains filtered or unexported fields
}

func NewSQLiteStore

func NewSQLiteStore(db *sql.DB) *SQLiteStore

func (*SQLiteStore) DeleteAllRefreshTokens

func (s *SQLiteStore) DeleteAllRefreshTokens(ctx context.Context, userId string) error

func (*SQLiteStore) DeleteRefreshToken

func (s *SQLiteStore) DeleteRefreshToken(ctx context.Context, hash string) error

func (*SQLiteStore) GetRefreshToken

func (s *SQLiteStore) GetRefreshToken(ctx context.Context, hash string) (*RefreshToken, error)

func (*SQLiteStore) SaveRefreshToken

func (s *SQLiteStore) SaveRefreshToken(ctx context.Context, token RefreshToken) error

type Seal

type Seal struct {
	// contains filtered or unexported fields
}

func NewSeal

func NewSeal(config *SealConfig) *Seal

func (*Seal) ExtractJTI

func (s *Seal) ExtractJTI(tokenString string) (jti string, remaining time.Duration, err error)

func (*Seal) GenerateAccessToken

func (s *Seal) GenerateAccessToken(userID string) (string, error)

func (*Seal) IssueTokens

func (s *Seal) IssueTokens(ctx context.Context, userID string) (accessToken string, refreshToken string, err error)

func (*Seal) Logout

func (s *Seal) Logout(ctx context.Context, userID string, rawToken string) error

func (*Seal) Middleware

func (s *Seal) Middleware() func(next http.Handler) http.Handler

func (*Seal) ParseToken

func (s *Seal) ParseToken(tokenString string) (userID string, jti string, err error)

func (*Seal) RefreshTokens

func (s *Seal) RefreshTokens(ctx context.Context, rawToken string) (accessToken string, refreshToken string, err error)

func (*Seal) RevokeAll

func (s *Seal) RevokeAll(ctx context.Context, userID string) error

func (*Seal) RevokeToken

func (s *Seal) RevokeToken(ctx context.Context, rawToken string) error

func (*Seal) ValidateAccessToken

func (s *Seal) ValidateAccessToken(tokenString string) (userID string, jti string, err error)

type SealConfig

type SealConfig struct {
	SecretKey       string
	AccessTokenTTL  time.Duration
	RefreshTokenTTL time.Duration
	Store           Store
	Blocklist       Blocklist // optional, for access token revocation
}

type SealError

type SealError struct {
	Type    string
	Message string
}

func NewSealError

func NewSealError(errorType, message string) *SealError

func (*SealError) Error

func (e *SealError) Error() string

func (*SealError) Is

func (e *SealError) Is(target error) bool

type Store

type Store interface {
	SaveRefreshToken(ctx context.Context, token RefreshToken) error
	GetRefreshToken(ctx context.Context, hash string) (*RefreshToken, error)
	DeleteRefreshToken(ctx context.Context, hash string) error
	DeleteAllRefreshTokens(ctx context.Context, userId string) error
}

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL