Identity Provider · OAuth2 Authorization Server
A self-hostable central authentication server in TypeScript, Express and MongoDB — OAuth2 with PKCE, JWKS-published rotating keys, refresh-token rotation with reuse detection, RBAC and a full audit log.
Private repository — source not public
Every new service started by rebuilding the same authentication layer, slightly differently. One had refresh tokens, one did not. One logged failed logins, one silently swallowed them. Roles were a string field in three different shapes. When a password policy had to change, it had to change in every service that had its own copy — and the copies had already drifted.
What was actually needed was one authorization server that every application could delegate to, self-hostable, with no per-user pricing attached to it.
A TypeScript authorization server on Express and MongoDB, implementing the Authorization Code flow with PKCE, federated through Google, issuing JWTs signed by auto-rotating keys published over a JWKS endpoint.
Sequence diagram: the client generates a verifier and derives an S256 challenge, calls the authorize endpoint, Signet binds the challenge to the state and redirects to Google, the callback resolves the identity, and the token exchange only succeeds when the SHA-256 of the verifier matches the stored challenge.
The token exchange is the part worth reading. The client sends the verifier only at exchange time, and the server compares its hash against the challenge it stored at authorization time:
const verifyPkce = (record: IAuthorizationCode, verifier: string): void => {
if (record.challengeMethod !== 'S256') {
throw new OAuthError('invalid_request', 'unsupported code_challenge_method');
}
const digest = createHash('sha256').update(verifier).digest();
const expected = Buffer.from(record.codeChallenge, 'base64url');
if (digest.length !== expected.length || !timingSafeEqual(digest, expected)) {
throw new OAuthError('invalid_grant', 'code_verifier does not match');
}
};timingSafeEqual rather than === because the comparison is against attacker-supplied input, and the length check comes first because timingSafeEqual throws on a length mismatch instead of returning false.
Every refresh mints a successor and retires its predecessor. If a retired token is ever presented again, that is not a race to tolerate — it is evidence that the token was copied.
State diagram: an issued token is active, presenting it rotates it and issues a successor, presenting a rotated token again marks it compromised which revokes the entire token family, and an unused token simply expires.
const rotate = async (presented: string): Promise<ITokenPair> => {
const record = await refreshTokens.findByHash(hash(presented));
if (!record) {
throw new OAuthError('invalid_grant', 'unknown refresh token');
}
if (record.rotatedAt !== null) {
await refreshTokens.revokeFamily(record.familyId, 'reuse_detected');
await auditLog.record('refresh.reuse_detected', { familyId: record.familyId, subject: record.subject });
throw new OAuthError('invalid_grant', 'refresh token already used');
}
return issueSuccessor(record);
};Tokens are stored as hashes, so a database dump does not hand over usable credentials. The whole family is revoked rather than the single token, because once a token has been copied there is no way to tell which of the two callers is the legitimate one.
Signing keys rotate on a schedule. Private keys are encrypted at rest; public keys are served from the JWKS endpoint, and a retiring key stays published for longer than the longest access-token lifetime so nothing fails verification mid-flight.
Flowchart: an RSA pair is generated, the private key is encrypted at rest and stored, the public key is published to the JWKS endpoint and becomes the signing key, rotation promotes a fresh key while the retiring key is still served through a grace window, and only after the longest token lifetime has passed is it dropped and destroyed.
RBAC with roles and permissions resolved at token-issue time, session management with explicit revocation, a password policy following NIST 800-63B — length over composition rules, and a check against known-breached passwords instead of forced quarterly rotation — and an append-only audit log covering every authentication decision.
This is the fair question, and for most projects the answer is use Auth0. Buying identity is usually correct: it is a security-critical component with a large surface, and a specialist vendor will maintain it better than you will.
Three things pushed the other way here.
The cost curve pointed the wrong way. Hosted identity is cheap at a few hundred users and becomes a per-user tax exactly as a product succeeds. For a set of internal services with an unbounded user count and no revenue per user attached to them, the pricing model was upside down.
It had to be in our infrastructure. An external service in the authentication path means an external dependency on every login, and user identity data leaving infrastructure we controlled. That was not acceptable for the environment this ran in.
The standard is the actual interface. The thing worth depending on is OAuth2 and OIDC, not a vendor's SDK. Implementing the specification directly means every client uses a standard library, and a future migration to a hosted provider is a configuration change rather than a rewrite of every application.
The honest cost: I now own the security of this code. That is only defensible because of the test suite — 800+ tests at 100% coverage — and because the scope stayed deliberately narrow. I implemented the flows actually needed, not the whole specification surface. No implicit flow, no resource owner password credentials, no device flow. Every flow I did not implement is a flow I do not have to keep secure.
Rotation costs a database write per refresh and makes the client slightly more complex, because it has to handle its refresh token changing under it. What it buys is detection: without rotation, a stolen refresh token is a silent, indefinite session. With it, the theft announces itself the moment either party uses a stale token.
Opaque tokens are easier to revoke — you delete the row. JWTs are verifiable without a round trip, which was the requirement. The trade-off is that an access token stays valid until it expires, so access-token lifetimes are short and revocation happens at the refresh boundary. Long-lived authority lives in the refresh token, which is checked against the database on every use.
Mostly a fit decision: the surrounding services were already on MongoDB, and identity records are document-shaped. It cost me transactional guarantees I would have had for free in PostgreSQL, which is why token rotation is written as a conditional update on the token's own document rather than a read-then-write.
In production, serving real applications as their authentication layer. Services no longer carry their own auth code; they validate a JWT against the JWKS endpoint and read roles from the token.
What I would revisit: the audit log and the token store share a database, so a heavy audit-write period competes with token verification. Splitting them is the first change I would make if traffic grew.