Skip to main content

OAuth Authorization Server — Issuing Tokens for Your Own App

This guideline covers an app acting as an OAuth 2.1 authorization server: it lets clients register, runs the login/consent flow against your existing user model, and issues access and refresh tokens. The spec mechanics (RFC 8414, 7591, 7636, 6749, 9728) live in a runner-agnostic enginecreateOAuthHandlers in @ttoss/auth-core — that operates on plain request/response objects, so any runtime can host it. @ttoss/http-server-auth ships the Koa adapter, oauthServer(); an AWS Lambda or GraphQL runner would adapt the same engine. Your app keeps its user model, signing keys, and login UI behind hooks.

RoleYou are…Covered by
OAuth serverissuing tokens for your own appthis guideline
OAuth clientobtaining tokens from a third partyOAuth Client
MCP applicationthe MCP-specific use of these primitivesMCP Server with OAuth

What ttoss owns vs. what your app owns

ttoss owns only the protocol: discovery metadata, PKCE verification, code exchange, and dynamic client registration. Everything app-specific stays behind pluggable hooks, so your user model, signing keys, and authentication never leave your app.

ttoss (createOAuthHandlers + oauthServer)Your app (hooks & stores)
/authorize, /token, /register wiringclientStore, authCodeStore — persistence
PKCE S256 verification, single-use codesonAuthorize — login + consent UI, bound to your user model
Discovery metadata (RFC 8414 / 9728)issueTokens — mint tokens with your signing keys
authorization_code + refresh_token flowonRefreshToken — validate refresh tokens

Setup

oauthServer() returns a Koa router you mount on your @ttoss/http-server app (it wraps the createOAuthHandlers engine). The four hooks below are the entire app-specific surface.

import { signJwt, verifyJwt } from '@ttoss/auth-core';
import { App, bodyParser } from '@ttoss/http-server';
import { oauthServer } from '@ttoss/http-server-auth';

const authServer = oauthServer({
issuer: 'https://api.example.com',
clientStore, // register/lookup clients in your datastore
authCodeStore, // short-lived codes + PKCE challenge in your datastore
scopesSupported: ['profile', 'write:posts'],

// App-owned token minting — ttoss never sees your signing keys.
issueTokens: async ({ subject, scopes }) => ({
accessToken: signJwt({
payload: { sub: subject, scope: scopes.join(' ') },
secret: process.env.JWT_SECRET!,
expiresInSeconds: 3600,
}),
refreshToken: signJwt({
payload: { sub: subject, scope: scopes.join(' ') },
secret: process.env.JWT_REFRESH_SECRET!,
expiresInSeconds: 60 * 60 * 24 * 30,
}),
expiresIn: 3600,
}),

// App-owned login/consent — read your own session, then approve or redirect.
// Runner-agnostic: you get the request headers, not a framework context.
onAuthorize: async ({ headers, request }) => {
const session = await getSession(headers.cookie);
if (!session) {
return { approved: false, redirect: '/login' };
}
return { approved: true, subject: session.userId, scopes: request.scopes };
},

// App-owned refresh validation — enables the refresh_token grant.
onRefreshToken: async ({ refreshToken }) => {
const payload = verifyJwt({
token: refreshToken,
secret: process.env.JWT_REFRESH_SECRET!,
});
if (!payload) return undefined; // reject — client must re-authorize
return {
subject: payload.sub as string,
scopes: (payload.scope as string).split(' '),
};
},
});

const app = new App();
app.use(bodyParser());
app.use(authServer.routes());

Discovery

Clients bootstrap by fetching metadata, so they need no manual configuration. The router serves /.well-known/oauth-authorization-server (RFC 8414) advertising the authorization_endpoint, token_endpoint, registration_endpoint, supported grants, and code_challenge_methods_supported: ['S256']. Set resource to also serve /.well-known/oauth-protected-resource (RFC 9728), which pairs a resource URL with this issuer as its authorization server.

Dynamic client registration

POST /register (RFC 7591) lets clients self-register: they post their redirect_uris and metadata, and the server issues a client_id (plus a client_secret for confidential clients) and persists it via clientStore.register. Your ClientStore only needs get(clientId) and register(client) — back it with DynamoDB, Postgres, or anything else.

Implement the optional verifyClientSecret({ clientId, clientSecret }) to keep secrets hashed at rest: the server hands over the value the client presented and your store compares it against its own stored form with verifyClientSecret from @ttoss/auth-core, so the raw secret never has to be recoverable. Without it the server compares the client_secret your get returns, which means the store must keep that value recoverable. @ttoss/auth-postgresdb implements it, so client_secret_hash is all its oauth_clients table holds.

Authorization endpoint and PKCE

GET /authorize validates the client_id and redirect_uri against the store, then calls your onAuthorize hook with the request and its headers. Return { approved: true, subject } once the user is authenticated and has consented — the server issues a single-use code bound to the user, the requested scopes, and the PKCE challenge. Return { approved: false, redirect } to send the user to your own login page (or { approved: false, status, body } for an inline response); the adapter performs it. PKCE S256 is mandatory (RFC 7636): the code_challenge is bound to the code and verified at the token endpoint, so codes are useless if intercepted.

The subject you return is the only link between OAuth and your user model — it is whatever stable user identifier you put in the issued token.

Token endpoint

POST /token handles two grants (RFC 6749). The authorization_code grant runs once at the end of login, verifying the PKCE code_verifier against the stored challenge before calling issueTokens and deleting the single-use code. The refresh_token grant lets a client renew an expired access token without sending the user back through login; it is enabled only when you supply onRefreshToken, which validates the presented token and returns the subject and scopes to re-issue (return undefined to reject). Omit onRefreshToken and refresh requests get unsupported_grant_type.

Refresh token rotation

A self-validating JWT refresh token (as in the setup example) is simple but cannot be revoked before it expires and offers no protection if it leaks. When that matters, use opaque, server-stored refresh tokens with rotation — the OAuth 2.1 recommendation. createRefreshRotation from @ttoss/auth-core implements the mechanics that are a common source of security bugs when hand-rolled, against any RefreshTokenStore backend (DynamoDB, Postgres, …): single use, expiry with sweep-on-access, scope narrowing, and reuse detection — replaying an already-rotated token revokes the owner's entire token set, forcing re-authentication.

Wire it through the two existing hooks: issue mints a tracked token inside issueTokens, and the ready onRefreshToken validates and rotates.

import { createRefreshRotation } from '@ttoss/auth-core';

const refresh = createRefreshRotation({ store: refreshTokenStore });

const authServer = oauthServer({
// …issuer, clientStore, authCodeStore, onAuthorize…
issueTokens: async ({ subject, scopes, client }) => ({
accessToken: signJwt({
payload: { sub: subject, scope: scopes.join(' ') },
secret: process.env.JWT_SECRET!,
expiresInSeconds: 3600,
}),
refreshToken: await refresh.issue({ client, subject, scopes }),
expiresIn: 3600,
}),
onRefreshToken: refresh.onRefreshToken,
});

The store persists only token hashes — plaintext tokens never touch your database — keyed so the (clientId, subject) owner is the unit revoked on reuse.

Stores

@ttoss/auth-core ships createMemoryClientStore, createMemoryAuthCodeStore, and createMemoryRefreshTokenStoreMap-backed implementations of the three store contracts. They are for tests, local development, and examples (state is lost on restart); production swaps in a durable backend behind the same interfaces.

For Postgres that backend is @ttoss/auth-postgresdb: register its oauthModels alongside your own so ttoss-postgresdb sync manages the tables, then build every store from the db handle.

import {
createPostgresdbOAuthStores,
oauthModels,
} from '@ttoss/auth-postgresdb';
import { initialize } from '@ttoss/postgresdb';

const db = await initialize({ models: { ...oauthModels, User } });

const { clientStore, authCodeStore, consentStore, refreshTokenStore } =
createPostgresdbOAuthStores({ db });

Writing a store by hand? Two details are not obvious from the interfaces. AuthCodeStore.get is handed the plaintext code, but the engine reads only the bound metadata off the result — so hash the code to look the row up and echo the presented value back, because a code that travels through a browser redirect must not sit in the database in plaintext. And a live refresh token's consumedAt must be absent, never null: rotation treats consumedAt !== undefined as reuse, so a nullable timestamp column makes the first refresh look like a replay and revokes the owner's whole token set.

createRedirectConsentOnAuthorize sends the user to an external consent page with the OAuth parameters — including redirect_uri — on the query string, and consumes the approval it records (see ConsentGrantStore). Two rules keep that page safe.

Never navigate to redirect_uri. The consent page cannot distinguish a genuine redirect from /authorize from a URL an attacker built and sent to a victim, so using that parameter to navigate turns an authenticated page into an open redirector. On approval, navigate to the authorization server's own /authorize with the parameters it was handed — the server re-validates redirect_uri against the registered client, so a forged one fails there. On cancel, render a terminal "nothing was connected" state; losing the OAuth-conformant error=access_denied redirect is the correct trade.

Render sign-in in place. If the consent route redirects an unauthenticated visitor to /login, the OAuth parameters have to be carried there and back — threading a single-use PKCE challenge through a redirect chain where it can land in logs or a Referer header. Render the sign-in form on the consent route instead, so the query string stays in exactly one place.

Scopes

Advertise the scopes your server issues via scopesSupported, and grant a subset per request in onAuthorize. Enforcement happens on the resource server that consumes the tokens: use authMiddleware's oauth strategy with requiredScopes (from @ttoss/http-server-auth) to gate an endpoint, or check scopes per-route in your handler. The same option flows through createMcpRouter's auth — see MCP Server with OAuth.

Pair this with MCP Server with OAuth when the tokens you issue protect an MCP server, and with OAuth Client for the inverse role — your app consuming a third-party provider's tokens.