A fully functional OAuth 2.0 authorization server built from scratch with Node.js, TypeScript, Express, and PostgreSQL. Implements the Authorization Code flow, Refresh Token flow with rotation and reuse detection, Client Credentials flow, token revocation, and a Bearer-token validation middleware — verified end to end with a real client application integration.
When you click "Sign in with Google," an OAuth server is doing the work behind the scenes — letting a third-party app access your data without ever seeing your password. This project implements that server. It issues scoped, revocable, time-limited access tokens to registered client applications on behalf of users, without exposing user credentials to those applications.
The whole system was validated with a separate test client app that performs the complete login dance across two servers, proving it works as a real integration and not just isolated endpoints.
OAuth solves delegated authorization — granting an application limited access to resources without sharing credentials. Instead of handing an app your password, the app receives an access token that:
- Grants only specific permissions (scope)
- Can be revoked without changing your password
- Expires automatically
OAuth is an authorization protocol (what you can do), not authentication (who you are). The login layer on top of it is a separate spec called OpenID Connect, which this project does not implement.
The full flow for when a user grants an app access to their resources.
- App redirects the user to
/authorizewith itsclient_id,redirect_uri, andscope - Server validates the client and shows a consent screen
- User approves; server issues a short-lived, single-use authorization code via browser redirect
- App's backend exchanges the code (plus its
client_secret) at/tokenfor an access token and refresh token
The intermediate code exists for security: it travels through the browser (a leaky channel — history, logs, Referer headers), but it's useless to an attacker because it's single-use, expires in 60 seconds, and can't be exchanged without the client_secret, which only the app's backend holds. The token itself is exchanged through a secure server-to-server back-channel.
Access tokens expire in 15 minutes. Rather than forcing the user to re-authorize, the app uses a long-lived refresh token to mint a new access token.
This implementation uses refresh token rotation — each refresh token works exactly once. When used, the old token is revoked and a new pair is issued. If a revoked refresh token is ever presented again, it signals possible theft (two parties holding the same token), and the request is rejected. This turns silent token theft into a detectable event.
For machine-to-machine communication where no user is involved — one backend service authenticating to another. The service authenticates with its own client_id and client_secret and receives an access token directly. No user, no consent, no authorization code, and no refresh token (the service can simply re-authenticate when needed).
A /revoke endpoint lets a client explicitly invalidate a token, such as on logout. Because the access and refresh tokens share a database row with a single revocation flag, revoking either one invalidates both — so logout fully cuts access and the refresh token can't be used to mint new tokens.
- Client secrets are hashed with bcrypt before storage; the plaintext is shown only once at registration and never recoverable.
- Exact
redirect_urimatching — registered redirect URIs are matched exactly, preventing attackers from redirecting authorization codes to their own servers. - Single-use authorization codes with 60-second expiry, preventing replay.
- Bearer token validation via middleware that verifies the JWT signature and checks the token hasn't been revoked in the database.
- No secrets in JWT payloads — JWT payloads are only base64-encoded (not encrypted), so client secrets never go inside them.
- Refresh token rotation with reuse detection.
Note: This server runs over HTTP for local development. In production, OAuth requires HTTPS/TLS — TLS encrypts the entire request including the body, which is why sending the
client_secretin the request body is safe. Without HTTPS, credentials would be exposed in transit.
Client App OAuth Server Database
| | |
| GET /authorize ───────► validate client + redirect_uri |
| show consent screen |
| ◄─── redirect w/ code issue single-use code ───────► auth_codes
| | |
| POST /token ──────────► verify secret + code |
| (code + secret) issue tokens ────────────────► tokens
| ◄─── access + refresh | |
| | |
| GET /userinfo ────────► requireAuth: verify JWT |
| (Bearer token) + check not revoked ─────────► tokens
| ◄─── user data | |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/v1/register |
Register a new client app; returns client_id and plaintext client_secret (shown once) |
GET |
/api/v1/authorize |
Validates client and renders the consent screen |
POST |
/api/v1/authorize |
Handles user approval; issues an authorization code and redirects |
POST |
/api/v1/token |
Token endpoint for all three grant types (authorization_code, refresh_token, client_credentials) |
GET |
/api/v1/userinfo |
Protected endpoint; returns user data for a valid Bearer token |
POST |
/api/v1/revoke |
Revokes a token (access or refresh) |
Four tables: clients, users, auth_codes, and tokens. Foreign keys cascade on delete, secrets and passwords are stored hashed, and token columns use TEXT to accommodate JWT length.
CREATE TABLE clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL UNIQUE,
client_secret VARCHAR(255) NOT NULL, -- bcrypt hash
redirect_uri VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL, -- bcrypt hash
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE auth_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID REFERENCES clients(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
code VARCHAR(255) NOT NULL UNIQUE,
scope VARCHAR(255) NOT NULL,
expires_at TIMESTAMP NOT NULL,
is_used BOOLEAN DEFAULT false
);
CREATE TABLE tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
client_id UUID REFERENCES clients(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
scope VARCHAR(255) NOT NULL,
access_token_expires_at TIMESTAMP NOT NULL,
refresh_token_expires_at TIMESTAMP NOT NULL,
is_revoked BOOLEAN DEFAULT false
);- Node.js + TypeScript
- Express
- PostgreSQL (via
pg) jsonwebtokenfor signing/verifying access and refresh tokensbcryptfor hashing client secrets and passwords
# Clone and install
git clone https://github.com/littlegod20/oauth-server.git
cd oauth-server
npm install
# Set up environment
cp .env.example .env # add DATABASE_URL and JWT_SECRET
# Create the database and tables
psql -U postgres -c "CREATE DATABASE oauth_server;"
psql -U postgres -d oauth_server -f schema.sql
# Run
npm run devDATABASE_URL=postgresql://user:password@localhost:5432/oauth_server
JWT_SECRET=your_long_random_secret
A separate test client application (in oauth-test-client/) demonstrates the complete integration. With both servers running, visiting http://localhost:4000/oauth triggers the entire Authorization Code flow: redirect to consent, approval, code exchange, and a protected /userinfo call — ending with the user's data rendered in the browser.
- PKCE for public clients (mobile apps, SPAs) that cannot safely store a client secret
- Token family revocation — tracking refresh token lineage with a
family_idto revoke an entire chain on reuse detection, rather than a single token - OpenID Connect layer for authentication (ID tokens)
- Scope enforcement on protected endpoints, not just scope storage
- HTTPS/TLS for production deployment