Stable:
v1.4.2—ghcr.io/gos0001/goauth:1
Identity service in a container. Users, passwords, sessions, and Ed25519-signed JWTs your services verify offline. It answers who is this user and nothing else — roles, plans and permissions stay in the service that owns them.
No admin UI, no realms, no plugin system. One binary, two connection URLs; it creates its own database and tables on start.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
volumes: [postgres_data:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 5
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 5
auth:
image: ghcr.io/gos0001/goauth:1
environment:
POSTGRES_URL: postgres://postgres:${POSTGRES_PASSWORD}@postgres:5432/goauth?sslmode=disable
REDIS_URL: redis://redis:6379
JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY:?}
JWT_AUDIENCE: my-app
SUPER_ADMIN_USERNAME: superadmin
SUPER_ADMIN_PASSWORD: ${SUPER_ADMIN_PASSWORD:?}
APP_ENV: production
ports: ["8080:8080"]
depends_on:
postgres: {condition: service_healthy}
redis: {condition: service_healthy}
restart: unless-stopped
volumes:
postgres_data:cat > .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 24)
JWT_PRIVATE_KEY=$(openssl rand -base64 32)
SUPER_ADMIN_PASSWORD=<your admin password, 12+ chars>
EOF
docker compose up -dThe admin credentials are whatever you put in .env — nothing is generated and
nothing is printed. Recreate the database and the same login still works.
Three things to know:
- goauth needs its own database (
/goauthabove, not your app's). It creates it, unless the role may not — thenCREATE DATABASE goauth;once by hand. - The database password is hex, not base64. Base64 contains
/, which ends the authority section of a connection URL and produces a baffling "invalid port" error. JWT_PRIVATE_KEYis required and never auto-generated: a new key per process would invalidate every issued token on restart.
POST /auth/token |
grant_type: password | refresh_token |
POST /auth/register |
only when AUTH_REGISTRATION_MODE=open |
GET /auth/settings |
registration mode, for the frontend |
GET /.well-known/jwks.json |
public verification keys |
GET /healthz |
With a bearer token: GET /auth/me, GET /auth/sessions,
POST /auth/password, POST /auth/revoke, POST /auth/logout-all.
Admin — user CRUD, block, force password, sessions, audit:
GET|POST /admin/users
GET|PATCH|DELETE /admin/users/{id}
POST /admin/users/{id}/password
GET|DELETE /admin/users/{id}/sessions
GET /admin/audit
Reachable two ways: on :8080 with a user JWT whose account has is_admin —
this is what a browser panel uses — and on :8081 with the static ADMIN_TOKEN
for machines. Never give that token to a browser, and do not publish 8081.
An expired token answers 401 {"error":"token expired"}, the same as
/auth/*, so a client refreshes and retries. Every other rejection answers a
bodiless 404, so an unauthorised caller cannot tell the surface from a route
that does not exist — retrying those will not help.
curl -X POST localhost:8080/auth/token -H 'Content-Type: application/json' \
-d '{"grant_type":"password","identifier":"superadmin","password":"..."}'Returns an access token (JWT, 15 min) and a refresh token (opaque, 30 days).
identifier is a username or email. Refresh tokens are single-use — each
exchange returns a new one, and replaying a spent one kills every session from
that login.
In your service, offline against the JWKS. Never call goauth per request.
import "github.com/gos0001/goauth/pkg/authclient"
mw, _ := authclient.New(authclient.Config{
JWKSURL: "http://auth:8080/.well-known/jwks.json",
Issuer: "goauth",
Audience: "my-app",
})
r.Use(mw.Require())
userID := authclient.UserID(c) // the `sub` claimAny language works — jose for Node, joserfc for Python. Check the signature,
iss, aud and exp, and restrict the algorithm to EdDSA.
Your service keeps its own user row and stores goauth's id beside it:
CREATE TABLE users (
id bigserial PRIMARY KEY,
external_id uuid NOT NULL UNIQUE, -- goauth's user id: the JWT `sub`
created_at timestamptz NOT NULL DEFAULT now()
);Upsert on the first authenticated request. Your subscriptions, roles and orders
reference users.id, never the auth UUID — that is what makes changing auth
provider a one-column change. No foreign key into goauth: separate databases
make one impossible, deliberately.
Set WEBHOOK_URL and goauth posts account lifecycle events — user.created,
updated, blocked, unblocked, deleted, password_changed,
admin_granted, admin_revoked — so your service keeps its own copy of a user
current without polling.
Events are written in the same transaction as the change, then delivered by a background worker with exponential backoff. A receiver being down delays events; it never loses them.
X-Goauth-Signature: sha256=<hmac of "<timestamp>.<body>">
X-Goauth-Timestamp: 1786550000
X-Goauth-Event: user.created
X-Goauth-Event-Id: <uuid> # stable across retries — deduplicate on it
X-Goauth-Attempt: 1
Delivered events are kept for WEBHOOK_RETENTION (7 days) and then removed.
Events nothing ever delivered — because delivery was switched off, or never ran —
are abandoned after OUTBOX_MAX_AGE (30 days), so the table cannot grow
unbounded either way.
Verify with WEBHOOK_SECRET; pkg/webhook.Verify is exported for Go receivers.
The timestamp is inside the signature so a captured request cannot be replayed.
WEBHOOK_API_KEY adds a plain X-Goauth-Api-Key header for gateways that filter
on one — an addition to the signature, not a replacement.
/plugin marketplace add gos0001/goauth
/plugin install goauth@goauth
Gives Claude the integration contract inside your project, plus /goauth-add
to put the service into your compose file and /goauth-verify to wire token
verification into your code.
| Variable | Default | |
|---|---|---|
POSTGRES_URL |
— | required |
REDIS_URL |
— | required |
JWT_PRIVATE_KEY |
— | required; base64 of 32 bytes |
JWT_AUDIENCE |
goauth |
set per application and check it |
JWT_ISSUER |
goauth |
|
JWT_ACCESS_TTL / JWT_REFRESH_TTL |
15m / 720h |
|
JWT_PREVIOUS_PUBLIC_KEYS |
— | retired keys, still published |
AUTH_REGISTRATION_MODE |
closed |
closed or open |
AUTH_MIN_PASSWORD_LEN |
12 |
|
AUTH_REGISTRATION_REQUIRE_EMAIL |
false |
goauth sends no mail and cannot verify one |
SUPER_ADMIN_USERNAME / _PASSWORD / _EMAIL |
— | seeds the first admin; applied at creation only |
APP_ADDR / ADMIN_ADDR |
:8080 / 127.0.0.1:8081 |
|
ADMIN_TOKEN |
— | empty disables the machine listener |
ADMIN_REAUTH_WINDOW |
15m |
password re-entry for destructive admin calls |
APP_ENV |
development |
production for JSON logs |
DB_AUTO_CREATE / DB_AUTO_SCHEMA |
true |
create the database / its tables |
ALLOW_DOMAINS |
— | CORS origins; empty blocks browsers |
CORS_MAX_AGE |
12h |
how long a browser caches the preflight |
TRUSTED_PROXIES |
— | CIDRs, cloudflare, or private |
CLIENT_IP_HEADER |
— | CF-Connecting-IP, X-Forwarded-For |
RATELIMIT_LOGIN_IP / _PAIR / _REGISTER |
100/15m / 10/15m / 20/1h |
|
RATELIMIT_FAIL_CLOSED |
true |
Redis down ⇒ refuse logins rather than allow them |
RATELIMIT_BACKOFF_AFTER / _BASE / _MAX |
3 / 1s / 60s |
per-account delay after wrong passwords, not a lock |
RATELIMIT_FAILURE_WINDOW |
15m |
how long those failures are remembered |
REDIS_KEY_PREFIX / REDIS_DEFAULT_TTL |
goauth: / 1h |
namespace, so Redis can be shared |
AUDIT_RETENTION / AUDIT_MAX_ROWS |
720h / 0 |
0 keeps everything |
AUDIT_CLEANUP_INTERVAL / SESSION_CLEANUP_INTERVAL |
6h / 1h |
|
WEBHOOK_URL / _SECRET / _API_KEY |
— | empty disables webhooks |
WEBHOOK_INTERVAL / _BATCH_SIZE / _TIMEOUT |
10s / 50 / 10s |
how often the outbox is drained, and how much per run |
WEBHOOK_MAX_ATTEMPTS / _BACKOFF_BASE / _BACKOFF_MAX |
10 / 10s / 1h |
retries before an event is abandoned |
WEBHOOK_RETENTION |
168h |
how long delivered events are kept |
OUTBOX_MAX_AGE / OUTBOX_CLEANUP_INTERVAL |
720h / 1h |
when undelivered events are dropped |
A browser panel or SPA runs on a different origin, so it needs
ALLOW_DOMAINS=https://app.example.com — https://*.example.com and * also
work. Empty means no CORS headers and browsers block the call. It governs
browsers only: curl and server-to-server calls are unaffected, so it is not an
access control.
Behind a proxy, set TRUSTED_PROXIES and CLIENT_IP_HEADER or rate limiting
counts the proxy instead of the caller. Empty is the safe default: no forwarding
header is believed.
git clone https://github.com/gos0001/goauth.git && cd goauth
make tools && cp .env.example .env.development
make jwt-key # paste into JWT_PRIVATE_KEY
make docker-up && make devmake generate runs sqlc then wire, make test runs the suite with -race.
The schema is schema/schema.sql, applied at startup — every statement is
idempotent, so changes are added as ALTER TABLE … ADD COLUMN IF NOT EXISTS.
MIT — see LICENSE.