One account for every Archway Point product. This service knows about accounts and nothing else — products verify tokens locally with the public key, so any number of them share one Archway ID with zero coupling and no shared secrets. The universal sign-in page lives at /; send users to /?redirect=<your-page> and read #archway_token off the fragment when they come back.
https://sso.archwaypoint.com (public, TLS)
http://192.168.3.31 (rack LAN — VM 102 "archway-sso")
Client ── email+password ──────────▶ Archway ID ── access JWT (15 min) + refresh
│ │
└── Authorization: Bearer <jwt> ──▶ Your product's server
verifies LOCALLY via /auth/jwks.json (checks aud!)
Node 22, zero-framework node:http, jose, Postgres (6 tables), one container behind Caddy. The Ed25519 keypair is generated on first boot and persisted in Postgres — tokens survive restarts. Public key at /auth/jwks.json (kid: archway-1). Passwords: scrypt (N=16384, r=8, p=1, 16-byte salt), constant-time compare, stored as s1$salt$hash.
| Endpoint | Body / header | Returns |
|---|---|---|
POST /auth/register | {email, password, client_id?} (pw ≥ 8) | {token, refresh, expires_in, account} · 409 taken |
POST /auth/login | {email, password, client_id?} | same · 401 on mismatch |
POST /auth/refresh | {refresh} | same — refresh is ROTATED, store the new one |
POST /auth/logout | {refresh} | {ok} |
POST /auth/logout-all | Bearer | {ok} — every session |
GET /auth/me | Bearer | {account: {id, email, email_verified}} |
POST /auth/change-password | {old, new} + Bearer | {ok} — revokes all sessions |
POST /auth/verify/request | Bearer | {ok} (re-send, rate limited) |
POST /auth/verify/confirm | {token} | {ok, account} (single-use, 24h) |
POST /auth/reset/request | {email} | {ok} ALWAYS — never leaks existence |
POST /auth/reset/confirm | {token, password} | {ok} (single-use, 1h) — revokes all sessions |
POST /auth/admin/revoke | {sub} + Bearer ADMIN_KEY | {ok} — ban support |
GET /auth/jwks.json | — | {keys: [publicJwk]} |
GET /auth/health | — | {ok, service, db, key} (readiness) |
Rate limit: 10 register/login attempts per IP per minute (429). Emails lower-cased, unique. Bodies capped at 10 KB.
Access token: JWT, alg: EdDSA, kid: archway-1, 15 minutes. Claims: sub = account UUID (immutable, never reused — key your data on this), aud = the client_id it was minted for, email, email_verified, sid, jti, iss: "archway-id", iat, exp.
Refresh token: opaque r1.<sid>.<secret>, ~30 days, stored hashed, rotated on every /auth/refresh — replaying a rotated-away token revokes the whole session family (theft response).
aud. A token minted for another product is not a credential here:jwtVerify(token, jwks, { issuer: "archway-id", audience: "yourproduct", clockTolerance: 60 })sso (default), prometheus, gamersfreedom, archwaypoint-admin (OIDC, public + PKCE). Check email_verified before linking accounts by email.Anything that speaks OAuth2/OIDC (Pelican OAuth schema, Laravel Socialite, NextAuth, passport) integrates with config only:
Discovery: https://sso.archwaypoint.com/.well-known/openid-configuration
Authorize: GET /auth/authorize (response_type=code, PKCE S256 REQUIRED)
Token: POST /auth/token (form-encoded or JSON; code + code_verifier)
Userinfo: GET /auth/userinfo (Bearer) -> {sub, email, email_verified}
JWKS: /auth/jwks.json (EdDSA archway-1 + RS256 archway-rsa-1)
id_tokens are RS256 (iss: https://sso.archwaypoint.com, 1h). Exact-match redirect allowlist per client, single-use 60s codes, S256 mandatory, state echoed. Register clients (admin): POST /auth/admin/clients {client_id, name, redirect_uris[], client_secret?}. Consent happens on the hosted /oauth.html page.
Every product can skin sign-in however it likes: the auth endpoints are plain JSON with permissive CORS, so your page calls them directly — no redirect to the hosted page required. Easiest path is the drop-in SDK served right from this host:
<script src="https://sso.archwaypoint.com/archway-id.js"></script>
<script>
// your own form, your own branding — the SDK is just the wire calls
ArchwayID.login(email, password) // or .register(email, password)
.then(({ account }) => start(account)) // token auto-stored in localStorage
.catch(err => showError(err.error)); // { status, error } — 401/409/429 pre-worded
ArchwayID.me().then(a => a && skipLogin(a)); // already signed in?
fetch("/api/thing", { headers: ArchwayID.authHeader() }); // call YOUR backend
</script>
Also on the SDK: changePassword(oldPw, newPw), token(), signOut(), configure({baseUrl}) for LAN installs, and hostedLoginUrl(returnTo) / consumeRedirectToken() if you'd rather bounce through the hosted page. Prefer raw HTTP? Skip the SDK — the table above is the whole API.
1 · Client: POST /auth/login (or register), keep token, hand it to your backend however you like.
3 · Config: one env var — AUTH_JWKS_URL=. Retry the JWKS fetch at boot so container ordering never matters.
2 · Server — verify locally, never call Archway ID per request:
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(
new URL(process.env.AUTH_JWKS_URL),
{ cooldownDuration: 60_000 });
const { payload } = await jwtVerify(token, jwks,
{ issuer: "archway-id" });
// payload.sub = the account id
v2 shipped: email verification, password reset (always-ok, hashed single-use tokens, revokes sessions), 15-min access + rotating refresh with reuse revocation, required aud, admin revoke-by-sub, readiness health. Still open: per-account lockout, account status in claims, key rotation, audit log, email-change flow, TOTP for staff, OIDC provider. Tokens live in localStorage — the 15-minute TTL bounds XSS damage, but keep your pages clean.
Reference implementation: src/index.ts (245 lines) in the archway-sso repo · consumers: Prometheus (server/src/verify.ts).