theauth

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Jun 20, 2026 License: MIT Imports: 20 Imported by: 0

README

theauth-go

A modern auth library for Go. Magic links, sessions, OAuth, MCP OAuth 2.1. Drop-in chi/net/http middleware. Postgres or in-memory storage.

Go Reference Go Report Card CI Release

theauth-go is a small, opinionated Go auth library. Sign in with email magic links or email + password today; OAuth, passkeys, and an MCP OAuth 2.1 server land on the published roadmap below.

It is built to drop into a chi or net/http server in under twenty lines, store sessions in Postgres or memory, and grow into agent identity (the part of auth most libraries skip) without a rewrite.


Why theauth-go

Who it's for

  • Go developers building web apps or APIs who want a real auth flow without owning every line of it
  • Teams building MCP servers and AI-agent backends who need agent identity, not just human login
  • Anyone who would otherwise reach for a SaaS auth vendor and would rather self-host

What's different

  • Go-native: idiomatic net/http handlers, a tiny Storage interface, chi-friendly middleware. Not a port of a TypeScript library
  • Agent identity on the roadmap: MCP OAuth 2.1 server, delegation chains, and budget policies are first-class plans (v2.0) — not bolted on
  • Self-hosted forever: MIT-licensed library, no per-MAU pricing, no vendor lock-in, your DB

What it isn't

  • Not a SaaS (no hosted dashboard, no managed UI)
  • Not for Node — see the TypeScript sibling glincker/theauth
  • Not a full IdP yet — OAuth providers ship in v0.3, SAML in v1.0

Install

go get github.com/glincker/theauth-go

Requires Go 1.25+ (matches pgx/v5).


Quickstart

package main

import (
    "net/http"

    "github.com/glincker/theauth-go"
    "github.com/glincker/theauth-go/storage/memory"
    "github.com/go-chi/chi/v5"
)

func main() {
    a, _ := theauth.New(theauth.Config{
        Storage: memory.New(),
        BaseURL: "http://localhost:8080",
    })

    r := chi.NewRouter()
    a.Mount(r) // wires /auth/* endpoints (magic-link, email-password, me, signout, ...)

    r.With(a.RequireAuth()).Get("/me", func(w http.ResponseWriter, r *http.Request) {
        user, _ := theauth.UserFromContext(r.Context())
        w.Write([]byte("hello " + user.Email))
    })

    http.ListenAndServe(":8080", r)
}

Email + password (v0.2) calls the same mounted routes:

// signup
resp, _ := http.Post("http://localhost:8080/auth/email-password/signup",
    "application/json", strings.NewReader(`{"email":"you@example.com","password":"twelve-chars-min"}`))
// signin same shape — POST /auth/email-password/signin

Full runnable example: examples/chi-app/.


Email + password (v0.2)

Mounting a.Mount(r) also wires up the /auth/email-password route group:

Method + path Body Notes
POST /auth/email-password/signup {email, password} Creates user, sets session cookie, sends verify
POST /auth/email-password/signin {email, password} Sets session cookie
POST /auth/email-password/forgot {email} Silently 200 even for unknown emails
POST /auth/email-password/reset {token, newPassword} Revokes all of the user's existing sessions

Built-in safeguards:

  • Argon2id hashing (OWASP 2026 defaults: 64 MiB / 3 iters / 4 threads)
  • Minimum 12-char password enforced in service layer (NIST 2024 baseline)
  • Per-IP rate limit (5/min default) on every credential endpoint
  • Per-email rate limit (3/min default) on signin + forgot
  • Anti-enumeration: unknown email and wrong password return the same invalid_credentials code; /forgot silently 200s for unknown emails
  • Soft email verification: signup issues a session immediately; the verify magic link is sent but signin does not block on user.emailVerifiedAt — gate sensitive features on the client side by checking /auth/me's emailVerifiedAt

Rate limits are configurable:

theauth.New(theauth.Config{
    RateLimitPerIP:    10,  // default 5
    RateLimitPerEmail: 5,   // default 3
    // ...
})

Error responses on v0.2 endpoints use a stable {code, message} JSON shape — switch on code:

Code HTTP When
weak_password 400 password < 12 chars
email_taken 409 signup with existing email
invalid_credentials 401 wrong password OR unknown email at signin
rate_limited 429 per-IP or per-email cap hit
password_reset_invalid 401 reset token unknown or already used
password_reset_expired 401 reset token past its TTL

Comparison

How theauth-go stacks up against the libraries and services you would actually consider in 2026:

Feature theauth-go better-auth Auth0 SDK Stytch Ory Kratos
Language Go TypeScript Multiple Multiple Go
Magic links Shipping Shipping Shipping Shipping Shipping
Email / password Shipping Shipping Shipping Shipping Shipping
OAuth providers Roadmap v0.3 17 30+ 20+ 10+
Passkeys / WebAuthn Roadmap v0.5 Shipping Shipping Shipping Shipping
Self-hosted Yes Yes No No Yes
MCP OAuth 2.1 server Roadmap v2.0 Roadmap v2.0 No No No
Agent identity + delegation Roadmap v2.0 Roadmap v2.0 No No No
Hosting model Library Library SaaS SaaS Service
Cost Free (MIT) Free (MIT) Paid Paid Free (Apache)

Honest legend: Shipping = available today, Roadmap vX = planned for that version, No = not planned. Numbers reflect each vendor's 2026 documentation.


Architecture

                ┌──────────────────────────────┐
   HTTP req ─►  │  chi / net/http router       │
                │   ├── a.Mount(r)             │  /auth/* handlers
                │   └── a.RequireAuth()        │  middleware
                └──────────────┬───────────────┘
                               │
                ┌──────────────▼───────────────┐
                │  theauth core                │
                │   ├── magic-link service     │
                │   ├── session service        │  opaque tokens, hashed in DB
                │   ├── password service       │  argon2id, rate-limited (v0.2)
                │   └── oauth / MCP / agents   │  (v0.3 → v2.0)
                └──────────────┬───────────────┘
                               │
                ┌──────────────▼───────────────┐
                │  Storage interface           │  pluggable
                │   ├── storage/memory         │  tests, demos
                │   └── storage/postgres       │  pgx + sqlc
                └──────────────────────────────┘

Sessions are opaque tokens — the raw token lives only in the user's cookie; only a SHA-256 hash is persisted. Revocation is a single UPDATE. The Storage interface is the only surface a custom backend needs to implement.


Storage backends

  • storage/memory — in-memory, zero deps. Use for tests, local demos, and quickstarts
  • storage/postgrespgx/v5 + sqlc-generated queries. Migrations live in storage/postgres/migrations/ and run via golang-migrate
  • Custom — implement the Storage interface (one type, focused method set) to back theauth with anything: SQLite, MySQL, DynamoDB, your existing ORM

Postgres example:

pool, _ := pgxpool.New(ctx, "postgres://...")
a, _ := theauth.New(theauth.Config{
    Storage: postgres.New(pool),
    BaseURL: "https://myapp.com",
})

Email senders

  • email.Noop — logs to stdout. Default. Good for local dev; never ship to production
  • email.SMTP — minimal SMTP sender (host, port, from). Lands in v0.3
  • Custom — implement email.Sender to wire Resend, Postmark, SES, SendGrid, etc.

Roadmap

  • v0.1 ✅ Magic links, sessions, chi middleware, Postgres + in-memory storage
  • v0.2 ✅ Email + password (signup, signin, forgot, reset), argon2id, per-IP + per-email rate limiting, structured TheAuthError type
  • v0.3 — GitHub OAuth (provider interface), refresh-token rotation, SMTP email sender
  • v0.4 — Google + Discord + Microsoft OAuth, TOTP 2FA
  • v0.5 — WebAuthn / passkeys
  • v1.0 — All 17 OAuth providers + SAML 2.0
  • v2.0 — MCP OAuth 2.1 server, agent identity, delegation chains, budget policies

Track the work in GitHub Issues and Releases.


FAQ

Is theauth-go production-ready?

v0.1 ships sessions and magic links and is covered by unit + integration tests against Postgres. v0.2 adds email + password with argon2id hashing, rate limiting, and anti-enumeration safeguards. It is appropriate for greenfield projects and side projects today. OAuth lands in v0.3. If you need OAuth, passkeys, or 2FA right now, check the roadmap and pick the right version — or use one of the alternatives above and migrate later.

Why not just use Auth0 or Clerk?

Both are excellent if you are happy paying per monthly active user and letting a third party hold your identity data. theauth-go exists for teams that want self-hosted, MIT-licensed, no-per-MAU-cost auth they fully control — including the code path that runs at login.

Why not Ory Kratos?

Kratos is a separate service you run alongside your app. theauth-go is a library you import — same process, same DB, same deploy. Fewer moving parts, less ops burden, but you give up Kratos's UI flows and multi-language SDK. Pick Kratos if you need a polyglot stack; pick theauth-go if your backend is Go and you want library-grade simplicity.

Why not better-auth?

better-auth is the TypeScript reference for this design. If your stack is Node/Next.js, use it (or use the sibling glincker/theauth TS implementation). theauth-go exists because the Go ecosystem deserves the same ergonomics natively, not via a Node sidecar.

Why a new Go auth library?

No Go library today combines agent identity + MCP OAuth 2.1 + traditional human auth in a single package. theauth-go is built for the moment human and agent auth converge — sessions, magic links, and email + password today, OAuth and passkeys in 2026, MCP OAuth 2.1 server + delegation in v2.0.

What is MCP OAuth 2.1?

The Model Context Protocol's authorization spec — built on RFC 9728 (Protected Resource Metadata), RFC 8707 (Resource Indicators), RFC 8414 (Authorization Server Metadata), and RFC 7591 (Dynamic Client Registration). It lets AI agents authenticate to MCP servers with proper scope, audience, and delegation. v2.0 of theauth-go will be the Go reference implementation.

Does it work with net/http only, no chi?

Yes. chi is recommended because middleware composition is cleaner, but Mount accepts anything that satisfies http.Handler registration, and RequireAuth() returns a standard func(http.Handler) http.Handler.

How are sessions stored?

Sessions are opaque tokens. The raw token is set in an HttpOnly, Secure, SameSite=Lax cookie. The DB only stores a SHA-256 hash plus metadata (user ID, created/expires, revoked flag). Revocation is a single UPDATE.


Contributing

  • Bug reports and feature requests: GitHub Issues
  • Questions, design discussion, RFC threads: GitHub Discussions
  • Pull requests welcome — please open an issue first for anything beyond a typo or one-file fix
  • Run go test ./... and go vet ./... before pushing

Sibling project

github.com/glincker/theauth — TypeScript implementation (formerly kavachos, rebranded 2026-06). Shares the same design language and roadmap; pick the one that matches your backend.

License

MIT — see LICENSE.

Documentation

Overview

Package theauth provides session-based authentication for Go applications.

TheAuth ships magic-link email auth, opaque session tokens with revocation, and chi-friendly middleware. Storage backends include in-memory and Postgres (pgx + sqlc). OAuth providers, TOTP, WebAuthn, and MCP OAuth 2.1 land in future versions — see the README roadmap.

Index

Constants

View Source
const (
	CodeWeakPassword         = "weak_password"
	CodeEmailTaken           = "email_taken"
	CodeInvalidCredentials   = "invalid_credentials"
	CodeRateLimited          = "rate_limited"
	CodePasswordResetExpired = "password_reset_expired"
	CodePasswordResetInvalid = "password_reset_invalid"
)

Stable error codes that callers can switch on. New endpoints return TheAuthError; old endpoints keep returning the sentinels above.

View Source
const MinPasswordLength = 12

MinPasswordLength is enforced at the library level (NIST 2024 baseline). No composition rules — NIST recommends against them. Consumers wanting stricter policies should layer them on top.

View Source
const PasswordResetTTL = time.Hour

PasswordResetTTL is how long a reset token stays valid after issuance. Reset tokens are single-use; this is also enforced atomically in storage.

Variables

View Source
var (
	ErrInvalidToken     = errors.New("theauth: invalid token")
	ErrSessionExpired   = errors.New("theauth: session expired")
	ErrUserNotFound     = errors.New("theauth: user not found")
	ErrMagicLinkExpired = errors.New("theauth: magic link expired")
	ErrMagicLinkUsed    = errors.New("theauth: magic link already used")
	ErrEmailNotVerified = errors.New("theauth: email not verified")

	// ErrStorageNotFound is the canonical "row missing" sentinel that storage
	// adapters return on lookup misses. Lives in the root package so service
	// code can errors.Is-check without importing the storage package
	// (which would create an import cycle).
	ErrStorageNotFound = errors.New("theauth: storage row not found")
)

Sentinel errors — retained for backward compatibility with v0.1 callers that errors.Is-check against them. New code should prefer TheAuthError + the Code* constants below.

Functions

This section is empty.

Types

type Config

type Config struct {
	Storage      Storage
	EmailSender  email.Sender
	BaseURL      string
	SigningKey   ed25519.PrivateKey
	SessionTTL   time.Duration
	MagicLinkTTL time.Duration
	CookieName   string
	SecureCookie bool
	// RateLimitPerIP is the per-IP per-minute budget applied to credential
	// endpoints (signup/signin/forgot/reset). Defaults to 5 when zero.
	RateLimitPerIP int
	// RateLimitPerEmail is the per-email per-minute budget applied to signin
	// + forgot. Defaults to 3 when zero.
	RateLimitPerEmail int
}

Config holds the wiring for a TheAuth instance.

Storage and BaseURL are required. Everything else has sensible defaults applied by New: SessionTTL=24h, MagicLinkTTL=15m, CookieName="theauth_session", EmailSender=email.Noop{}. SigningKey is reserved for future JWT signing (v0.2+); v0.1 uses opaque tokens and leaves the field nil.

type MagicLink struct {
	ID        ULID       `json:"id"`
	Email     string     `json:"email"`
	TokenHash []byte     `json:"-"`
	ExpiresAt time.Time  `json:"expiresAt"`
	UsedAt    *time.Time `json:"usedAt,omitempty"`
	CreatedAt time.Time  `json:"createdAt"`
}

type PasswordResetToken added in v0.2.0

type PasswordResetToken struct {
	ID        ULID       `json:"id"`
	UserID    ULID       `json:"userId"`
	TokenHash []byte     `json:"-"`
	ExpiresAt time.Time  `json:"expiresAt"`
	UsedAt    *time.Time `json:"usedAt,omitempty"`
	CreatedAt time.Time  `json:"createdAt"`
}

PasswordResetToken backs the /auth/email-password/forgot+reset flow. Shape mirrors MagicLink but binds to a known user_id (resets always operate on an existing account), and lives in its own table to keep flows isolated.

type Session

type Session struct {
	ID        ULID       `json:"id"`
	UserID    ULID       `json:"userId"`
	TokenHash []byte     `json:"-"` // never serialize raw hash
	UserAgent string     `json:"userAgent"`
	IP        string     `json:"ip"`
	CreatedAt time.Time  `json:"createdAt"`
	ExpiresAt time.Time  `json:"expiresAt"`
	RevokedAt *time.Time `json:"revokedAt,omitempty"`
}

func SessionFromContext

func SessionFromContext(ctx context.Context) (*Session, bool)

SessionFromContext returns the Session attached by Authn middleware, if any. Returns false when the request is anonymous.

func (Session) Expired

func (s Session) Expired(now time.Time) bool

Expired reports whether the session is no longer usable at the given time.

type Storage

type Storage interface {
	// Users
	CreateUser(ctx context.Context, u User) (User, error)
	UserByEmail(ctx context.Context, email string) (*User, error)
	UserByID(ctx context.Context, id ULID) (*User, error)
	MarkEmailVerified(ctx context.Context, userID ULID) error

	// Sessions
	CreateSession(ctx context.Context, s Session) (Session, error)
	SessionByTokenHash(ctx context.Context, hash []byte) (*Session, error)
	RevokeSession(ctx context.Context, id ULID) error
	RevokeUserSessions(ctx context.Context, userID ULID) error

	// Magic links
	CreateMagicLink(ctx context.Context, ml MagicLink) error
	ConsumeMagicLink(ctx context.Context, tokenHash []byte) (*MagicLink, error)

	// Email + password (v0.2)
	SetUserPassword(ctx context.Context, userID ULID, passwordHash string) error
	// UserByEmailWithPassword fetches a user along with their stored PHC hash.
	// passwordHash is "" if the account exists but has never set a password
	// (e.g. magic-link-only signup). Callers should treat empty hash as
	// "no password credential available" and surface invalid_credentials.
	UserByEmailWithPassword(ctx context.Context, email string) (user *User, passwordHash string, err error)
	CreatePasswordResetToken(ctx context.Context, t PasswordResetToken) error
	ConsumePasswordResetToken(ctx context.Context, tokenHash []byte) (*PasswordResetToken, error)
}

Storage is the persistence contract TheAuth depends on. Adapters live in sub-packages (storage/memory, storage/postgres). Defined here so that service code in this package can reference it without importing the storage sub-package (which would create an import cycle, because storage imports this package for the model types).

The storage package re-exports this as storage.Storage so consumers can keep importing it from the conventional location.

type TheAuth

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

TheAuth is the public entry point — constructed once at app start and shared across handlers.

func New

func New(cfg Config) (*TheAuth, error)

New validates the Config, applies defaults, and returns a ready TheAuth.

func (*TheAuth) Authn

func (a *TheAuth) Authn() func(http.Handler) http.Handler

Authn looks for a session cookie, validates it, and adds the user + session to the request context. Does NOT reject anonymous requests — pair with RequireAuth.

func (*TheAuth) Mount

func (a *TheAuth) Mount(r chi.Router)

Mount wires TheAuth's HTTP routes onto the supplied chi router under /auth. Routes:

POST   /auth/magic-link                       request a magic link
GET    /auth/magic-link/verify                consume a magic link, set session cookie
POST   /auth/email-password/signup            create user with email + password (rate-limited)
POST   /auth/email-password/signin            sign in with email + password (rate-limited)
POST   /auth/email-password/forgot            request a password reset link (rate-limited)
POST   /auth/email-password/reset             consume a reset token + set new password (rate-limited)
GET    /auth/me                               return the authenticated user (RequireAuth)
DELETE /auth/sessions/current                 revoke the current session (RequireAuth)

Default rate limits: 5/min per source IP on every credential endpoint, plus 3/min per email on signin + forgot (most attack-surface). All limits are in-memory + per-process; replace at the LB layer for multi-instance deploys.

func (*TheAuth) RateLimitByEmail added in v0.2.0

func (a *TheAuth) RateLimitByEmail(perMinute int) func(http.Handler) http.Handler

RateLimitByEmail returns a middleware that limits requests per email body field. Reads the JSON body up to 16 KiB, extracts "email", restores the body so downstream handlers can re-read it. Requests without a parseable email are passed through unlimited (handler will reject them on its own).

func (*TheAuth) RateLimitByIP added in v0.2.0

func (a *TheAuth) RateLimitByIP(perMinute int) func(http.Handler) http.Handler

RateLimitByIP returns a middleware that limits requests per source IP to perMinute per minute. Use on credential endpoints (signin, signup, forgot, reset). The limiter lives on the returned handler — multiple calls produce independent buckets, so wire it once per route group at startup.

func (*TheAuth) RequireAuth

func (a *TheAuth) RequireAuth() func(http.Handler) http.Handler

RequireAuth runs Authn, then rejects requests that don't have a session.

type TheAuthError added in v0.2.0

type TheAuthError struct {
	Code    string
	Message string
	Inner   error
}

TheAuthError is the structured error type returned by v0.2+ service methods. Callers can errors.As-extract it and switch on Code for stable handling, or errors.Is-check against a value of the same Code for shorter paths.

func NewError added in v0.2.0

func NewError(code, message string, inner error) *TheAuthError

NewError constructs a TheAuthError with the supplied code, message, and optional wrapped cause.

func (*TheAuthError) Error added in v0.2.0

func (e *TheAuthError) Error() string

func (*TheAuthError) Is added in v0.2.0

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

Is reports whether target is a *TheAuthError with the same Code, OR is the Inner cause. This lets callers do errors.Is(err, &TheAuthError{Code: ...}) for code-only comparisons without caring about the message or inner cause.

func (*TheAuthError) Unwrap added in v0.2.0

func (e *TheAuthError) Unwrap() error

Unwrap exposes the inner error for errors.Is/errors.As traversal.

type ULID

type ULID = ulid.ULID

ULID is the canonical ID type — generated in app, stored as uuid in Postgres.

type User

type User struct {
	ID              ULID       `json:"id"`
	Email           string     `json:"email"`
	EmailVerifiedAt *time.Time `json:"emailVerifiedAt,omitempty"`
	Name            string     `json:"name"`
	AvatarURL       string     `json:"avatarUrl"`
	CreatedAt       time.Time  `json:"createdAt"`
	UpdatedAt       time.Time  `json:"updatedAt"`
}

func UserFromContext

func UserFromContext(ctx context.Context) (*User, bool)

UserFromContext returns the authenticated User attached by Authn middleware, if any. Returns false when the request is anonymous.

Directories

Path Synopsis
internal
mcpresource module
postgres
Package postgres provides a Postgres-backed storage.Storage implementation built on top of sqlc-generated queries and pgx/v5.
Package postgres provides a Postgres-backed storage.Storage implementation built on top of sqlc-generated queries and pgx/v5.

Jump to

Keyboard shortcuts

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