A production-ready authentication service built with Express and better-auth, providing secure user authentication with email/password and OAuth social providers.
- Framework: Express 5
- Authentication: better-auth
- Database: PostgreSQL (auth schema)
- Language: TypeScript
- Runtime: Node.js 24
- Email/password authentication
- GitHub OAuth integration
- JWT-based sessions
- Rate limiting
- CORS support
- Secure cookie handling
- PostgreSQL with separate auth schema
- Node.js 24+
- PostgreSQL 17+
- GitHub OAuth App (for social login)
- Install dependencies:
npm install- Copy and configure environment variables:
cp .env.example .env- Copy and configure Liquibase properties:
cp migrations/liquibase.properties.example migrations/liquibase.properties- Generate a secure secret key:
# Generate a cryptographically secure secret (min 32 characters)
openssl rand -base64 32-
Configure your
.envfile with:DATABASE_URL- PostgreSQL connection string (format:postgresql://user:password@host:port/database?schema=auth)BETTER_AUTH_SECRET- Use the generated secret key from step 4 (min 32 characters)GITHUB_CLIENT_IDandGITHUB_CLIENT_SECRET- GitHub OAuth credentials (optional)SENDGRID_API_KEY- Email service credentials (optional for development)FRONTEND_URL- Your frontend URL for CORS (default:http://localhost:5173)
-
Configure
migrations/liquibase.propertieswith:url- JDBC connection URL (format:jdbc:postgresql://host:port/database)username- Database usernamepassword- Database password
Note: This file is gitignored as it contains database credentials. Never commit it to version control.
The auth API uses a separate auth schema in PostgreSQL. Database migrations are managed by Liquibase:
- Navigate to the migrations folder:
cd ../migrations- Run Liquibase migrations:
liquibase update -Dcontexts=developmentNote: Better-auth will also automatically create/update its required tables on first run.
# Run in development mode with hot reload
npm run dev
# Build TypeScript
npm run build
# Run production build
npm startProduction: https://auth.domain.com
Staging: https://auth-staging.domain.com
Local Development: http://localhost:3002
All auth routes are at the root path (no /api/auth/ prefix):
POST /sign-up- Register new userPOST /sign-in- Sign in with email/passwordPOST /sign-out- Sign out userGET /session- Get current sessionGET /github- GitHub OAuth flowGET /callback/github- GitHub OAuth callbackGET /jwks- JWKS endpoint for JWT validationPOST /token/introspect- Machine-authenticated bearer-token activity checkPOST /workload/principals/grants- Operator-authorized principal creation or key-rotation grantPOST /workload/token- DPoP-bound grant exchangePOST /workload/token/renew- DPoP-bound rotating credential renewalPOST /workload/token/introspect- Machine-authenticated workload-token activity checkPOST /workload/revoke- Operator revocation by tokenjti, workloadprincipal_id, orcredential_family_id
GET /health- Health check endpointGET /- API info
- Helmet.js for security headers
- Rate limiting (100 requests per 15 minutes)
- CORS configuration for trusted origins
- Secure cookie settings in production
- Password hashing with bcrypt
- JWT token signing
docker build -f Dockerfile.dev -t auth-api:dev .
docker run -p 3002:3002 --env-file .env auth-api:devThe production Dockerfile uses multi-stage builds for optimal image size and security:
# Build production image
docker build -t auth-api:latest .
# Run production container
docker run -p 3002:3002 --env-file .env auth-api:latestProduction Features:
- Multi-stage build for minimal image size
- Non-root user for enhanced security
- Health checks for container orchestration
- Signal handling with dumb-init
- Production-only dependencies
| Variable | Description | Default |
|---|---|---|
PORT |
Server port | 3002 |
NODE_ENV |
Environment | development |
DATABASE_URL |
PostgreSQL connection string | - |
POSTGRES_POOL_MAX |
Maximum PostgreSQL connections per auth-api replica (1-20) | 20 |
BETTER_AUTH_SECRET |
Secret for JWT signing (min 32 chars) | - |
BETTER_AUTH_URL |
Base URL for auth (subdomain: auth.domain.com) | http://localhost:3002 |
JWT_AUDIENCE |
Exact JWT audience required by relying services | BETTER_AUTH_URL |
TOKEN_INTROSPECTION_CLIENT_ID |
Non-secret audit label for the machine client | token-introspection-client |
TOKEN_INTROSPECTION_BEARER_TOKEN |
Secret-manager-provisioned credential for /token/introspect |
- |
TOKEN_INTROSPECTION_RATE_LIMIT_MAX |
Per-minute introspection request limit | 120 |
WORKLOAD_IDENTITY_ENABLED |
Explicitly enable workload identity routes | false |
WORKLOAD_JWT_AUDIENCE |
Required workload audience, separate from JWT_AUDIENCE |
- |
WORKLOAD_TOKEN_ENDPOINT_URL |
Canonical public DPoP htu for initial exchange |
- |
WORKLOAD_RENEWAL_ENDPOINT_URL |
Canonical public DPoP htu for renewal |
- |
WORKLOAD_OPERATOR_BEARER_TOKEN |
Secret-manager credential for grant and revocation operations | - |
WORKLOAD_TOKEN_TTL_SECONDS |
Workload token lifetime, bounded to 60-900 seconds | 300 |
WORKLOAD_GRANT_TTL_SECONDS |
Default one-time grant lifetime for operator calls that omit expires_in, bounded to 30-300 seconds |
300 |
WORKLOAD_RENEWAL_TTL_SECONDS |
Rolling renewable credential lifetime, bounded to one hour through one year | 31536000 |
WORKLOAD_DPOP_CLOCK_SKEW_SECONDS |
DPoP proof clock window, bounded to 5-60 seconds | 60 |
WORKLOAD_RATE_LIMIT_MAX |
Per-minute workload-route request limit | 120 |
GITHUB_CLIENT_ID |
GitHub OAuth client ID | - |
GITHUB_CLIENT_SECRET |
GitHub OAuth client secret | - |
FRONTEND_URL |
Frontend URL for CORS (domain.com) | http://localhost:5173 |
API_URL |
Core API URL for CORS (api.domain.com) | http://localhost:3001 |
SESSION_EXPIRES_IN |
Session duration in seconds | 86400 |
SESSION_UPDATE_AGE |
Session refresh interval | 3600 |
The introspection URL is the configured issuer with the root path appended:
POST <BETTER_AUTH_URL without a trailing slash>/token/introspect
Do not use /api/auth/token/introspect or append the route to the JWKS URL. The
machine client sends its dedicated, externally provisioned credential as
Authorization: Bearer <TOKEN_INTROSPECTION_BEARER_TOKEN> and JSON body
{"token":"<original bearer JWT>"}. Configure each relying service's expected
audience to exactly match JWT_AUDIENCE. Generate a random machine credential
of at least 32 characters and provision the same value to auth-api and the
authorized machine client through their secret managers.
Bearer JWTs use the PostgreSQL Better Auth session ID as jti. PostgreSQL is
the durable activity authority while Redis remains secondary session storage.
Sign-out, explicit session revocation, and password reset remove affected
session records. Setting auth.users.disabled = TRUE also deletes that user's
sessions through a database trigger, while application and database guards
reject new sessions until the user is re-enabled. Later re-enablement therefore
cannot reactivate old tokens. Revoked rows are deleted; active rows remain until
their configured session expiry. Positive introspection responses may be cached
for at most 30 seconds; inactive and error responses are non-cacheable.
Rollout is fail-closed: JWTs issued before this feature lack a session jti,
and Redis-only sessions are not backfilled into PostgreSQL. Users must
reauthenticate to obtain an introspectable bearer token after deployment.
Workload identity is an opt-in, consumer-neutral issuer profile. It reuses the
same Better Auth RS256 signing keys and /jwks publication as human JWTs, but
uses a separate required audience, short lifetime, PostgreSQL activity state,
and token_use=workload. Deployments must configure a consumer-specific
audience; the repository does not hard-code a downstream system or audience.
createBetterAuthWorkloadTokenAdapter is the temporary compatibility boundary
for Better Auth 1.6.x. It supplies the missing workload claim and verification
profile while keeping route dependencies stable for a later native Better Auth
replacement after the 1.7 feature set reaches a stable release.
The principal flow is deliberately registrar-to-workload-client:
- The registrar requests principal creation through
POST /workload/principals/grantswithmode=createand the RFC 7638 thumbprint of the client-generated P-256 public key. Auth-api generates the opaqueprincipal_id; callers cannot choose it. The trusted operator may include an integerexpires_infrom 60 through 86,400 seconds; omitting it usesWORKLOAD_GRANT_TTL_SECONDS. Supplyingrenewable=trueopts the grant into rotating credentials; omitted or false preserves the original response. Key rotation usesmode=rotate, an existingprincipal_id, and the replacement thumbprint. - Auth-api returns the
principal_idand an opaque, short-lived grant once, storing only the grant's SHA-256 digest. Consumers own any mapping from that principal to their authorization or resource model. - The workload client exchanges that grant at
POST /workload/tokenwith an ES256 DPoP proof bound to the configured canonical endpoint URL. Grant consumption, proof replay storage, principal activation, and issued-jtipersistence are transactional. - A renewable exchange adds
renewal_credential,renewal_credential_expires_in,credential_family_id, andrenewal_generation; non-renewable exchange responses are unchanged. - Renewal sends
{ "renewal_credential": "..." }, a fresh DPoP proof from the enrolled key, andIdempotency-Key. It does not require a live access token, so renewal remains possible after access-token expiry. Rotation is serialized and invalidates the prior credential. The same credential and request key replay the same replacement for two minutes; reuse with another key revokes the family.
Existing non-renewable clients may continue the original empty-body
Authorization: DPoP <access-token> renewal with an ath-bound proof. The
renewable credential branch is additive and does not remove that contract.
Issued claims are exactly iss, the configured aud, principal UUID sub,
jti, iat, exp, token_use=workload, and cnf.jkt. Tenant, resource,
capability, or other consumer authorization data is never accepted, persisted,
or signed. A browser cookie or human bearer token cannot mint a workload token.
Proof validation uses exact configured htu, HTTP method, bounded iat, a
single-use proof jti, ES256, and a public-only P-256 JWK.
The existing TOKEN_INTROSPECTION_BEARER_TOKEN protects
POST /workload/token/introspect. Positive results expose only verified token
claims, and all workload introspection responses use no-store so revocation
is visible immediately. POST /workload/revoke, protected by a distinct
operator credential, soft-revokes one jti, one renewable credential family,
or an entire principal immediately. Principal revocation also revokes all of
its families. Raw grants, renewal credentials, access tokens, DPoP proofs, and
operator credentials must never be logged or stored. Renewal credentials are
derived with an HKDF-domain-separated key from BETTER_AUTH_SECRET; only their
SHA-256 digests and the minimal two-minute replay metadata are persisted.
The configured exchange and renewal URLs must use the issuer's public origin.
DPoP replay tombstones are intentionally retained; auth-api does not delete production records automatically. Operators must monitor the expiry index and perform separately authorized retention maintenance for expired tombstones.
mTLS is not part of this profile. If later enabled, it requires an
operator-managed CA and a distinct cnf["x5t#S256"] contract with no downgrade
from a configured binding.
# Run tests
npm test
# Test coverage
npm run test:coverageThis repository is designed to work as a standalone service or as a Git submodule in a larger monorepo.
When first cloning a parent repository that includes this as a submodule:
# In the parent repository
git submodule update --init --recursive
cd auth-api
npm install
cp .env.example .env
cp migrations/liquibase.properties.example migrations/liquibase.properties
# Configure both files with your credentials# Update submodule to latest commit
git submodule update --remote auth-api
# Commit submodule changes in parent repo
git add auth-api
git commit -m "Update auth-api submodule"The following files are gitignored and must be configured locally:
.env- Application environment variablesmigrations/liquibase.properties- Database migration credentials
These files contain project-specific credentials and should never be committed to version control.
MIT License - See LICENSE file for details