diff --git a/.gitbook.yaml b/.gitbook.yaml
new file mode 100644
index 0000000..23dad3a
--- /dev/null
+++ b/.gitbook.yaml
@@ -0,0 +1,5 @@
+root: ./docs/
+
+structure:
+ readme: README.md
+ summary: SUMMARY.md
diff --git a/.gitignore b/.gitignore
index 3ed2b87..9855e94 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,3 @@
-/docs/
/lib/
/.shards/
/.vscode/
diff --git a/README.md b/README.md
index be80280..7822ece 100644
--- a/README.md
+++ b/README.md
@@ -1,88 +1,32 @@
# Authly
-[](https://app.codacy.com/manual/eliasjpr/authly?utm_source=github.com&utm_medium=referral&utm_content=eliasjpr/authly&utm_campaign=Badge_Grade_Settings) [](https://github.com/azutoolkit/authly/actions/workflows/crystal.yml)
+[](https://app.codacy.com/manual/eliasjpr/authly?utm_source=github.com&utm_medium=referral&utm_content=eliasjpr/authly&utm_campaign=Badge_Grade_Settings)
+[](https://github.com/azutoolkit/authly/actions/workflows/crystal.yml)

-
A robust OAuth2 and OpenID Connect authentication library for Crystal
+
A standards-compliant OAuth 2.0, OpenID Connect, and Resource Server security shard for Crystal
-Authly is a production-ready Crystal library that provides secure, standards-compliant OAuth2 and OpenID Connect authentication capabilities. Built with developer experience in mind, Authly offers a comprehensive suite of grant types, flexible token strategies, and intuitive APIs that make implementing authentication straightforward and maintainable.
+**Authly** is a zero-framework OAuth 2.0 and OpenID Connect library built directly on Crystal's standard `HTTP::Handler`. Mount it as an **Authorization Server** (`Authly::Handler`), protect your API routes as a **Resource Server** (`Authly::Handler::Bearer` and `Authly::Handler::RequireScope`), or run both together in the same process or across microservices.
-## Table of Contents
-
-- [Features](#features)
-- [Installation](#installation)
-- [Quick Start](#quick-start)
-- [Configuration](#configuration)
-- [OAuth2 Grant Types](#oauth2-grant-types)
-- [Authentication Endpoints](#authentication-endpoints)
-- [Customization](#customization)
- - [Custom Authorizable Client](#custom-authorizable-client)
- - [Custom Authorizable Owner](#custom-authorizable-owner)
- - [Custom Token Store](#custom-token-store)
-- [Token Strategies](#token-strategies)
-- [Protecting Your API](#protecting-your-api)
- - [Bearer Handler](#bearer-handler)
- - [Requiring Scopes](#requiring-scopes)
- - [JWKS and Metadata](#jwks-and-metadata)
- - [Mounting in an Azu App](#mounting-in-an-azu-app)
-- [Roadmap](#roadmap)
-- [Contributing](#contributing)
-- [License](#license)
-- [Contact](#contact)
-
-## Features
-
-Authly provides a comprehensive authentication solution with the following capabilities:
-
-**OAuth2 Grant Types**
-
-- ✅ Authorization Code Grant (with PKCE support)
-- ✅ Client Credentials Grant
-- ✅ Resource Owner Password Credentials Grant
-- ✅ Implicit Grant
-- ✅ Refresh Token Grant
-- ✅ Device Authorization Grant
-
-**OpenID Connect**
-
-- ✅ ID Token generation and validation
-- ✅ Standard claims support
-- ✅ Custom claims integration
-- ✅ UserInfo endpoint
-- ✅ OpenID Connect Discovery (/.well-known/openid-configuration)
-- ✅ OAuth2 Dynamic Client Registration (RFC 7591)
-
-**Token Management**
-
-- ✅ JWT and Opaque token strategies
-- ✅ Token introspection endpoint
-- ✅ Token revocation endpoint
-- ✅ Configurable token expiration (TTL)
-- ✅ Pluggable token storage backend
-
-**Security**
+---
-- ✅ PKCE (Proof Key for Code Exchange) support with S256 enforcement
-- ✅ Certificate-bound access tokens (mTLS / RFC 8705)
-- ✅ JWT Secured Authorization Response Mode (JARM / RFC 9101)
-- ✅ State parameter validation
-- ✅ Redirect URI validation
-- ✅ Secure token generation
-- ✅ Multiple JWT algorithms support (HS256, RS256, etc.)
+## Key Capabilities
-**Developer Experience**
+| Area | Supported Standards & Features |
+| :--- | :--- |
+| **OAuth 2.0 Grants** | Authorization Code (`authorization_code`), Client Credentials (`client_credentials`), Refresh Token (`refresh_token` with rotation & family reuse detection), Device Authorization (`urn:ietf:params:oauth:grant-type:device_code`), Cryptographic Challenge-Response (`challenge_response` / `urn:ietf:params:oauth:grant-type:challenge`), Password (`password`), Implicit (`implicit`) |
+| **OpenID Connect** | `id_token` issuance (`nonce`, `acr`, `amr`, `auth_time`), `GET /oauth/userinfo`, OIDC Discovery (`/.well-known/openid-configuration`), OAuth 2.0 Authorization Server Metadata (`/.well-known/oauth-authorization-server` — RFC 8414), JWKS (`/.well-known/jwks.json` — RFC 7517) |
+| **FAPI & Advanced Security** | PKCE with `S256` enforcement (RFC 7636), Pushed Authorization Requests (`POST /oauth/par` — RFC 9126), JWT Secured Authorization Response Mode (JARM — RFC 9101), Certificate-Bound Access Tokens (`cnf.x5t#S256` mTLS — RFC 8705), Dynamic Client Registration (`POST /oauth/register` — RFC 7591) |
+| **Token Lifecycle** | `:jwt` (`HS*`, `RS*`, `ES*`) and `:opaque` token strategies, asymmetric key rotation (`signing_key_id` + `verification_keys`), Token Introspection (`POST /oauth/introspect` — RFC 7662), Token Revocation (`POST /oauth/revoke` — RFC 7009), host-controlled `IssuanceOptions` |
+| **Resource Server Protection** | `Authly::Handler::Bearer` (RFC 6750 verification, audience checking, `context.authly_claims!`) and `Authly::Handler::RequireScope` (route-level `all` / `any` scope enforcement) |
-- ✅ Intuitive, type-safe API
-- ✅ Comprehensive documentation
-- ✅ Easy-to-configure HTTP handlers
-- ✅ Flexible customization points
-- ✅ Built-in middleware support
+---
## Installation
-Add Authly to your `shard.yml`:
+Add `authly` to your `shard.yml`:
```yaml
dependencies:
@@ -96,1406 +40,171 @@ Then run:
shards install
```
-**Requirements:**
+**Requirements:** Crystal `>= 1.22.0`
-- Crystal 1.22.0 or higher (CI runs 1.22.1)
-- A supported operating system (Linux, macOS, or Windows)
+---
## Quick Start
-Get up and running with Authly in minutes:
+Create a working OAuth 2.0 Authorization Server and protected Resource API in a single file:
```crystal
+require "http/server"
+require "json"
require "authly"
-# Configure Authly
Authly.configure do |config|
- config.issuer = "https://your-app.com"
- config.secret_key = ENV["AUTHLY_SECRET_KEY"]
- config.access_ttl = 1.hour
- config.refresh_ttl = 1.day
- config.token_strategy = :jwt
+ config.issuer = "http://localhost:8080"
+ config.base_url = "http://localhost:8080"
+ config.secret_key = ENV.fetch("AUTHLY_SECRET_KEY", "dev-secret-change-in-production-32b")
config.algorithm = JWT::Algorithm::HS256
-end
-
-# Start the authentication server
-server = HTTP::Server.new([
- Authly::Handler.new,
-])
-
-server.bind_tcp("0.0.0.0", 8080)
-puts "Authly server listening on http://0.0.0.0:8080"
-server.listen
-```
-
-This minimal setup provides a working OAuth2 server with JWT token support. For production use, you'll want to implement custom clients, owners, and token storage (see [Customization](#customization)).
-
-## Configuration
-
-Authly offers extensive configuration options to tailor authentication to your application's needs:
-
-```crystal
-Authly.configure do |config|
- # Issuer Configuration
- config.issuer = "https://your-app.com"
- config.base_url = "https://your-app.com" # Base URL for OpenID Connect Discovery
-
- # Security Keys
- config.secret_key = ENV["AUTHLY_SECRET_KEY"] # Required for JWT signing
- config.public_key = ENV["AUTHLY_PUBLIC_KEY"] # Optional, for asymmetric algorithms
-
- # Token Time-to-Live (TTL)
- config.access_ttl = 1.hour # Access token expiration
- config.refresh_ttl = 1.day # Refresh token expiration
- config.code_ttl = 5.minutes # Authorization code expiration
-
- # Token Strategy
- config.token_strategy = :jwt # Options: :jwt or :opaque
- config.algorithm = JWT::Algorithm::HS256 # JWT signing algorithm
-
- # Resource-owner login (REQUIRED for /oauth/authorize and /device, see "Authorization Endpoint")
- config.authenticate_user = ->(context : HTTP::Server::Context) { MySession.user_id(context) }
- config.login_url = ->(context : HTTP::Server::Context) { "/login?return_to=#{URI.encode_www_form(context.request.resource)}" }
-
- # PKCE Enforcement
- config.enforce_pkce = false # Require PKCE for all authorization code flows
- config.enforce_pkce_s256 = false # Enforce S256 method only (reject plain)
-
- # Dynamic Client Registration
- config.allow_dynamic_registration = false
- config.client_store = InMemoryClientStore.new
-
- # Certificate-Bound Tokens (mTLS)
- config.require_certificate_bound_tokens = false
- config.trusted_ca_certificates = [] of String
-
- # Custom Implementations (see Customization section)
- config.owners = CustomAuthorizableOwner.new
- config.clients = CustomAuthorizableClient.new
- config.token_store = CustomTokenStore.new
- config.state_store = CustomStateStore.new
- config.device_code_store = CustomDeviceCodeStore.new
-end
-```
-
-### Configuration Options
-
-| Option | Type | Description | Default |
-| ---------------- | ------------------ | ------------------------------------ | ----------- |
-| `issuer` | String | The issuer identifier for tokens | Required |
-| `secret_key` | String | Secret key for JWT signing | Required |
-| `public_key` | String | Public key for asymmetric algorithms | `nil` |
-| `access_ttl` | Time::Span | Access token lifetime | `1.hour` |
-| `refresh_ttl` | Time::Span | Refresh token lifetime | `1.day` |
-| `refresh_absolute_ttl` | Time::Span | Longest a sign-in can be renewed by rotating refresh tokens | `30.days` |
-| `code_ttl` | Time::Span | Authorization code lifetime | `5.minutes` |
-| `token_strategy` | Symbol | Token type (`:jwt` or `:opaque`) | `:jwt` |
-| `algorithm` | JWT::Algorithm | JWT signing algorithm | `HS256` |
-| `owners` | AuthorizableOwner | Resource owner authentication | Required |
-| `clients` | AuthorizableClient | Client authentication | Required |
-| `token_store` | TokenStore | Token persistence layer | In-memory |
-
-## OAuth2 Grant Types
-
-Authly implements all standard OAuth2 grant types, allowing you to choose the appropriate authentication flow for your use case:
-
-### Authorization Code Grant
-
-The most secure OAuth2 flow, recommended for server-side applications. Supports PKCE for enhanced security.
-
-**Use Case:** Web applications with a backend server, mobile applications
-
-**Endpoint:** `POST /oauth/token`
-
-```crystal
-# Request parameters
-{
- "grant_type": "authorization_code",
- "code": "AUTH_CODE",
- "redirect_uri": "https://your-app.com/callback",
- "client_id": "CLIENT_ID",
- "client_secret": "CLIENT_SECRET",
- "code_verifier": "CODE_VERIFIER" # Optional, for PKCE
-}
-```
-
-### Client Credentials Grant
-
-Used for server-to-server authentication where the client acts on its own behalf.
-
-**Use Case:** Microservices, background jobs, API integrations
-
-**Endpoint:** `POST /oauth/token`
-
-```crystal
-# Request parameters
-{
- "grant_type": "client_credentials",
- "client_id": "CLIENT_ID",
- "client_secret": "CLIENT_SECRET",
- "scope": "read write" # Optional
-}
-```
-
-### Resource Owner Password Credentials Grant
-
-Allows exchanging username and password directly for tokens. Only use for highly trusted applications.
-
-**Use Case:** First-party applications, legacy system migrations
-
-**Endpoint:** `POST /oauth/token`
-
-```crystal
-# Request parameters
-{
- "grant_type": "password",
- "username": "USER_NAME",
- "password": "PASSWORD",
- "client_id": "CLIENT_ID",
- "client_secret": "CLIENT_SECRET",
- "scope": "read write" # Optional
-}
-```
-
-### Implicit Grant
-
-Returns tokens directly from the authorization endpoint without an intermediate code exchange.
-
-**Use Case:** Single-page applications (deprecated in favor of Authorization Code with PKCE)
-
-**Endpoint:** `GET /oauth/authorize`
-
-### Refresh Token Grant
-
-Allows obtaining a new access token without requiring user interaction.
-
-**Use Case:** Maintaining user sessions, token renewal
-
-**Endpoint:** `POST /oauth/token`
-
-```crystal
-# Request parameters
-{
- "grant_type": "refresh_token",
- "refresh_token": "REFRESH_TOKEN",
- "client_id": "CLIENT_ID",
- "client_secret": "CLIENT_SECRET",
- "scope": "read" // optional; may only narrow the scope of the original grant
-}
-```
-
-A refresh token is **single use**. The answer carries a new refresh token (same family) and the old one stops working.
-Presenting an already used refresh token revokes the whole family, so a leaked token is detected the first time
-either party uses it: the client must sign in again. A refresh token must be a `refresh_token`-typed token issued to
-the authenticating client, unexpired and not revoked; the access token it yields keeps the original scope, and the
-client must still be allowed that scope (`invalid_scope` otherwise, the token is not consumed). Rotation cannot renew a
-sign-in for ever: the family ends `refresh_absolute_ttl` (30 days) after the sign-in (`fexp` claim, which no token
-outlives), then the user signs in again. Revoking a refresh token revokes its whole family. Refresh tokens are not
-access tokens: `Authly.valid?`, introspection and the bearer handler refuse them.
-
-**Upgrading:** refresh tokens issued by earlier versions (without `cid`, `fid` and `scope`) are refused: users sign in
-again. A custom `Authly::TokenGenerator` must override `generate_refresh_token(client_id, jti, sub, scope, family)`
-and put `cid`, `scope`, `fid` and `token_type: "refresh_token"` in the token, otherwise its refresh tokens cannot be
-redeemed. A custom `TokenStore` needs an atomic `consume` (see "Custom Token Store"); the family is revoked through
-`revoke("refresh-family:")`; override `revoke(token_id, ttl)` too to expire that key after `refresh_ttl`
-(the default ignores the TTL). A custom generator should also override the six-argument
-`generate_refresh_token(..., family, family_expires_at)` and emit `fexp`; without it the absolute lifetime is not enforced.
-
-### Device Authorization Grant
-
-Enables authentication on devices with limited input capabilities.
-
-**Use Case:** Smart TVs, IoT devices, CLI tools
-
-**Endpoints:**
-
-- `POST /oauth/device/authorize` - Request device code
-- `POST /oauth/token` - Exchange device code for tokens
-
-## Authentication Endpoints
-
-Authly provides standard OAuth2 endpoints for authentication flows:
-
-### Pushed Authorization Request Endpoint
-
-**Path:** `POST /oauth/par`
-
-Allows clients to push authorization parameters directly to the authorization server, receiving a short-lived `request_uri` for use in the authorization flow (RFC 9126).
-
-**Request:**
-
-```http
-POST /oauth/par HTTP/1.1
-Content-Type: application/x-www-form-urlencoded
-Authorization: Basic base64(client_id:client_secret)
-
-response_type=code&
-redirect_uri=https://client.example.com/callback&
-scope=openid profile&
-state=xyz&
-code_challenge=CHALLENGE&
-code_challenge_method=S256
-```
-
-**Response:**
-
-```json
-{
- "request_uri": "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY",
- "expires_in": 90
-}
-```
-
-See [Pushed Authorization Requests](#pushed-authorization-requests-par) for detailed information.
-
-### Authorization Endpoint
-
-**Path:** `GET /oauth/authorize` (consent page) and `POST /oauth/authorize` (the owner's decision)
-
-Initiates the authorization flow for grant types requiring user interaction.
-
-Authly does not own the login: the host application tells it who is signed in.
-
-```crystal
-Authly.configure do |config|
- # Returns the subject id of the signed-in resource owner, or nil. It is the ONLY source of the
- # subject of a code or token: `user_id` and `consent` request parameters are ignored.
- config.authenticate_user = ->(context : HTTP::Server::Context) { MySession.user_id(context) }
-
- # Optional: where to send a visitor who is not signed in (otherwise 401 `login_required`).
- config.login_url = ->(context : HTTP::Server::Context) { "/login?return_to=#{URI.encode_www_form(context.request.resource)}" }
-
- config.consent_ttl = 10.minutes # how long a consent form stays valid
-end
-```
-
-While `authenticate_user` is unset the authorization endpoint and the device page refuse every request
-(500, nothing is issued). The request is validated first (client, exact `redirect_uri` match, response type,
-grant type, requested scope against the client's scopes), then the signed-in owner gets a consent page whose
-form carries a signed, single-use token binding client, redirect URI, scope, state and the owner. Only a POST
-with that token, from the same signed-in owner, issues the code or token. The pages are served with
-`Content-Security-Policy`, `X-Frame-Options: DENY`, `Cache-Control: no-store` and `Referrer-Policy: no-referrer`.
-
-**Running more than one node.** Consent and device forms are signed with a key derived from `config.secret_key`, whose
-default is random per process: every node must be configured with the same `secret_key`, and the nonces live in
-`config.state_store`, which must be shared too (the in-memory default is per process). A custom `StateStore` must make
-`consume(state) : Bool` atomic (it answers `true` to exactly one caller, e.g. Redis `DEL` returning 1); the handlers use
-only `consume`, never `valid?` followed by `consume`.
-
-**Security notes.** Authorization codes without PKCE are accepted by default and `plain` is a valid method. Set
-`config.enforce_pkce = true` and `config.enforce_pkce_s256 = true` for every deployment that has public clients (and
-preferably all of them). The scope of an authorization request is required and must be within the client's scopes.
-
-**Parameters:**
-
-- `response_type` - Type of response (`code`, `token`)
-- `client_id` - Client identifier
-- `redirect_uri` - Callback URL
-- `scope` - Requested permissions (optional)
-- `state` - CSRF protection token (recommended)
-- `code_challenge` - PKCE challenge (optional)
-- `code_challenge_method` - PKCE method (`S256` or `plain`)
-
-### Token Endpoint
-
-**Path:** `POST /oauth/token`
-
-Exchanges authorization grants for access tokens.
-
-**Parameters:** Vary by grant type (see [OAuth2 Grant Types](#oauth2-grant-types))
-
-### Introspection Endpoint
-
-**Path:** `POST /oauth/introspect` (also `/introspect`)
-
-Validates and retrieves metadata about a token. The caller authenticates as a registered client, with HTTP Basic
-(`client_id:client_secret`) or `client_id` and `client_secret` in the form; otherwise `401 invalid_client`. Use one
-of the two: a Basic header that cannot be read is a failed authentication, the form is not tried instead.
-
-```crystal
-# Request
-{
- "token": "ACCESS_TOKEN",
- "token_type_hint": "access_token" # Optional
-}
-
-# Response
-{
- "active": true,
- "client_id": "CLIENT_ID",
- "username": "USER_NAME",
- "exp": 1640000000,
- "iat": 1639999000,
- "scope": "read write"
-}
-```
-
-### Revocation Endpoint
-
-**Path:** `POST /oauth/revoke` (also `/revoke`)
-
-Invalidates an access or refresh token, with either token strategy. The client authenticates as for introspection and
-can revoke only tokens issued to it; for any other token the answer is the same `200` and nothing is revoked (RFC 7009).
-
-```crystal
-# Request
-{
- "token": "TOKEN_TO_REVOKE",
- "token_type_hint": "access_token" # Optional
-}
-```
-
-### Device Authorization Endpoints
-
-**Request Device Code:** `POST /oauth/device/authorize`
-
-```crystal
-# Response
-{
- "device_code": "DEVICE_CODE",
- "user_code": "USER_CODE",
- "verification_uri": "https://your-app.com/device",
- "expires_in": 300,
- "interval": 5
-}
-```
-
-**Verify Device Code:** `GET /oauth/device/verify`
-
-User visits this endpoint to enter the user code and authorize the device.
-
-The page needs `Authly.config.authenticate_user`. Every lookup of a user code is counted per signed-in owner and per source (approving takes two: the page and the
-form) and, past `device_attempt_limit` (10) of them within `device_attempt_window` (15 minutes), the page answers `429`
-with `Retry-After` until the window passes. A code is decided once: approving an approved or denied code, or denying an
-approved one, is refused. When the owner denies, the device is answered `access_denied` (RFC 8628 3.5) until the code expires. A device that polls the token endpoint faster than `interval` is answered
-`slow_down` and its interval grows by 5 seconds. An approved device code yields one token, about the owner who approved it.
-
-```crystal
-Authly.configure do |config|
- config.device_attempt_limit = 10
- config.device_attempt_window = 15.minutes
- # Behind a reverse proxy the socket address is the proxy: return the client address it vouches for
- config.attempt_source = ->(context : HTTP::Server::Context) { context.request.headers["X-Real-IP"]? }
- # With several nodes, count in a shared store: implement Authly::AttemptLimiter (exceeded?, increment) over Redis
- # config.attempt_limiter = RedisAttemptLimiter.new # the device page
- # config.registration_limiter = RedisAttemptLimiter.new # registration has its own, so one cannot fill the other
-end
-```
-
-`X-Real-IP` in the example is a request header: a client can send any value, so an attacker rotates it to get a fresh
-counter every time **unless your reverse proxy overwrites it** (`proxy_set_header X-Real-IP $remote_addr;` in nginx,
-rather than passing the client's value on) and the app is reachable only through that proxy. Without such a proxy keep
-the default (the socket address) or return nil to count by account only.
-
-**Shared addresses:** the source is one key. At registration everything behind one proxy address (when
-`attempt_source` returns the proxy's address) counts as one source, and so do all requests when it returns nil (they
-share the key `unknown`): one client that fails often enough locks out all the others behind that key for the window.
-On the device page nil means no source key: lookups are counted by account only. There every lookup counts, successful
-ones too, so with the proxy's address as the source 10 lookups per 15 minutes from everyone behind it lock all of them
-out. Return an address your proxy vouches for, per client.
-
-**Boundaries:** at the device page the first `device_attempt_limit` lookups of an owner (or of a source) in a window are
-answered normally and the next one is `429`. At registration the first `registration_rate_limit` failed authentications of
-a source are `401`, the next one `429`, and once that many failures are counted even a request with the right token is `429`.
-
-**Shared limiters:** the handlers count with `increment(key, window) : Int32`, which has to count and answer in one atomic
-step, otherwise parallel requests all pass the check before any is counted. For Redis that is `INCR key`, then
-`EXPIRE key window` when the answer is `1` (or one Lua script); `exceeded?(key, limit)` is a plain read of the same
-counter and `record` is `increment` whose answer is ignored. `InMemoryAttemptLimiter` keeps at most 10,000 keys: when
-full it drops expired keys first and, if all are live, counts no new key and answers a count no limit reaches, so
-the handler refuses (fail closed) instead of forgetting a live counter. A custom limiter written for 1.3.1 must add `increment`.
-
-**Upgrading:** a custom `DeviceCodeStore` must persist the new `DeviceCodeData#last_polled_at` (it is part of the
-record, and `JSON::Serializable` stores it by default); without it `slow_down` never triggers.
-
-### UserInfo Endpoint
-
-**Path:** `GET /oauth/userinfo`
-
-Returns user claims based on the granted scopes in the access token.
-
-**Request:**
-
-```http
-GET /oauth/userinfo HTTP/1.1
-Authorization: Bearer ACCESS_TOKEN
-```
-
-**Response:**
-
-```json
-{
- "sub": "user_id_123",
- "name": "John Doe",
- "email": "john@example.com",
- "email_verified": true,
- "picture": "https://example.com/avatar.jpg"
-}
-```
-
-**Supported Scopes:**
-
-- `openid`: Returns `sub` (subject/user ID)
-- `profile`: Returns profile information (name, picture, etc.)
-- `email`: Returns email and email_verified
-- `address`: Returns address information
-- `phone`: Returns phone number information
-
-### OpenID Connect Discovery Endpoint
-
-**Path:** `GET /.well-known/openid-configuration`
-
-Returns OpenID Connect discovery metadata for automatic client configuration.
-
-**Response:**
-
-```json
-{
- "issuer": "https://auth.example.com",
- "authorization_endpoint": "https://auth.example.com/oauth/authorize",
- "token_endpoint": "https://auth.example.com/oauth/token",
- "userinfo_endpoint": "https://auth.example.com/oauth/userinfo",
- "jwks_uri": "https://auth.example.com/.well-known/jwks.json",
- "registration_endpoint": "https://auth.example.com/oauth/register",
- "scopes_supported": ["openid", "profile", "email", "address", "phone"],
- "response_types_supported": ["code", "token", "id_token"],
- "grant_types_supported": ["authorization_code", "client_credentials", ...],
- "code_challenge_methods_supported": ["S256", "plain"]
-}
-```
-
-### Dynamic Client Registration Endpoint
-
-**Path:** `POST /oauth/register`
-
-Allows clients to dynamically register with the authorization server (RFC 7591).
-
-**Configuration Required:**
-
-```crystal
-Authly.configure do |config|
- config.allow_dynamic_registration = true
- config.client_store = CustomClientStore.new
-
- # Registration is never anonymous. Give callers an initial access token (RFC 7591), a hook, or both;
- # with neither the endpoint answers 500 and registers nothing.
- config.registration_access_token = ENV["REGISTRATION_ACCESS_TOKEN"] # Authorization: Bearer
- config.authenticate_registration = ->(context : HTTP::Server::Context) { admin?(context) } # Bool
-
- # What a client may ask for (the defaults are shown); anything else is `invalid_client_metadata`
- config.registration_grant_types = ["authorization_code", "refresh_token"]
- config.registration_response_types = ["code"]
- config.registration_scopes = ["read"] # none by default: a request with a `scope` is refused
-
- config.registration_max_body = 16 * 1024 # bytes, else 413
- config.registration_rate_limit = 10 # failed authentications per source ...
- config.registration_rate_window = 1.hour # ... per window, else 429 (requests with the right credentials are not counted)
-end
-```
-
-Only failed authentications count against a source, since they are what guesses the initial access token; a source
-that used up its failures is refused even with the right token until the window passes, so the limit caps the guesses.
-The source is `Authly.config.attempt_source`: behind a proxy it is only as trustworthy as that hook (see the note on
-`X-Real-IP` under the device flow).
-
-`redirect_uris` (1 to 10) must be exact URIs of one of three kinds: `https`, `http` on a loopback host (`localhost`,
-`127.0.0.1`, `[::1]`, RFC 8252), or a private-use scheme named after a domain (`com.example.app:/callback`). A fragment,
-user info, wildcard (`*`), blank or control character is refused, so a registered URI can only match itself (the
-authorization endpoint compares redirect URIs exactly). `client_uri`, `logo_uri`, `tos_uri`, `policy_uri` and `jwks_uri` must be `https`.
-Secrets are 256 random bits (`Random::Secure`).
-
-**Upgrading:** the endpoint used to accept anyone, and `password`, `implicit`, any scope and any `scheme://host` URI.
-Set `registration_access_token` or `authenticate_registration`, and widen the allow-lists your clients need.
-Registered clients are stored as given to `ClientStore`; Authly's own `Clients` list does not read that store, and the
-secret is stored as issued (a store that must not hold it in the clear can hash it before saving).
-
-**Request:**
-
-```json
-{
- "redirect_uris": ["https://client.example.com/callback"],
- "client_name": "My Application",
- "client_uri": "https://client.example.com",
- "logo_uri": "https://client.example.com/logo.png",
- "grant_types": ["authorization_code", "refresh_token"],
- "response_types": ["code"],
- "token_endpoint_auth_method": "client_secret_basic",
- "scope": "openid profile email",
- "contacts": ["admin@client.example.com"]
-}
-```
-
-**Response:**
-
-```json
-{
- "client_id": "client_abcdef123456",
- "client_secret": "secret_xyz789",
- "client_id_issued_at": 1640000000,
- "client_secret_expires_at": 0,
- "redirect_uris": ["https://client.example.com/callback"],
- "client_name": "My Application",
- "grant_types": ["authorization_code", "refresh_token"],
- "response_types": ["code"],
- "token_endpoint_auth_method": "client_secret_basic"
-}
-```
-
-**Public Clients:**
-
-For public clients (e.g., SPAs, mobile apps), set `token_endpoint_auth_method` to `"none"`:
-
-```json
-{
- "redirect_uris": ["https://spa.example.com/callback"],
- "token_endpoint_auth_method": "none"
-}
-```
-
-Public clients receive a `client_id` but no `client_secret`.
-
-## Advanced Security Features
-
-### Enhanced PKCE with S256 Enforcement
-
-PKCE (Proof Key for Code Exchange) is a security extension for OAuth2 that prevents authorization code interception attacks. Authly now supports enforcing PKCE and mandating the S256 method.
-
-**Configuration:**
-
-```crystal
-Authly.configure do |config|
- # Require PKCE for all authorization code flows
- config.enforce_pkce = true
-
- # Enforce S256 method, reject plain method
- config.enforce_pkce_s256 = true
-end
-```
-
-**Usage:**
-
-When PKCE is enforced, all authorization requests must include:
-
-- `code_challenge`: The code challenge derived from the code verifier
-- `code_challenge_method`: Must be `S256` when enforcement is enabled
-
-**Example Authorization Request:**
-
-```http
-GET /oauth/authorize?
- response_type=code&
- client_id=CLIENT_ID&
- redirect_uri=REDIRECT_URI&
- code_challenge=CHALLENGE&
- code_challenge_method=S256&
- state=STATE
-```
-
-**Code Verifier Generation:**
-
-```crystal
-# Generate a cryptographically random code verifier
-verifier = Random::Secure.hex(32)
-
-# Calculate the S256 challenge
-challenge = Digest::SHA256.base64digest(verifier)
-```
-
-**Token Exchange:**
-
-```http
-POST /oauth/token
-Content-Type: application/x-www-form-urlencoded
-
-grant_type=authorization_code&
-code=AUTH_CODE&
-client_id=CLIENT_ID&
-redirect_uri=REDIRECT_URI&
-code_verifier=VERIFIER
-```
-
-### Pushed Authorization Requests (PAR)
-
-Pushed Authorization Requests (RFC 9126) is a security enhancement that moves authorization parameters from the front-channel (browser) to a back-channel (direct server-to-server) request. This prevents parameter tampering, reduces URL length limitations, and provides better security for sensitive authorization data.
-
-**Configuration:**
-
-```crystal
-Authly.configure do |config|
- # Enable PAR endpoint (always available)
- config.par_ttl = 90.seconds # Request URI expiration (default: 90 seconds)
-
- # Optionally require PAR for all authorization requests
- config.require_par = true
-
- # Optional: Custom PAR store implementation
- config.par_store = CustomPARStore.new
-end
-```
-
-**How It Works:**
-
-PAR operates in two steps:
-
-**Step 1: Push Authorization Parameters (Back-channel)**
-
-The client sends authorization parameters directly to the authorization server:
-
-```http
-POST /oauth/par HTTP/1.1
-Host: auth.example.com
-Content-Type: application/x-www-form-urlencoded
-Authorization: Basic base64(client_id:client_secret)
-
-response_type=code&
-redirect_uri=https://client.example.com/callback&
-scope=openid profile email&
-state=xyz&
-code_challenge=CHALLENGE&
-code_challenge_method=S256
-```
-
-**Response:**
-
-```json
-{
- "request_uri": "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY",
- "expires_in": 90
-}
-```
-
-**Step 2: Authorization Request (Front-channel)**
-
-The client redirects the user with only the `request_uri`:
-
-```http
-GET /oauth/authorize?
- client_id=CLIENT_ID&
- request_uri=urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY
-```
-
-**Benefits:**
-
-- **Enhanced Security**: Parameters aren't exposed in browser URLs or server logs
-- **Prevents Tampering**: Authorization parameters can't be modified by attackers
-- **Large Requests**: Bypasses URL length limitations for complex authorization requests
-- **Server-Side Validation**: Parameters validated before user interaction
-- **Replay Protection**: Request URIs are single-use and time-limited
-- **FAPI Compliant**: Required for Financial-grade API security profiles
-
-**Client Authentication:**
-
-PAR requires client authentication using one of:
-
-- `client_secret_basic`: HTTP Basic authentication (recommended)
-- `client_secret_post`: Client credentials in request body
-
-**Security Features:**
-
-- Request URIs expire after 90 seconds (configurable)
-- Single-use request URIs (consumed after authorization)
-- Client ID validation to prevent unauthorized use
-- Automatic cleanup of expired request URIs
-
-### Certificate-Bound Access Tokens (mTLS)
-
-Certificate-bound tokens (RFC 8705) bind access tokens to client certificates, preventing token theft and replay attacks. This feature requires mTLS (mutual TLS) configuration at the HTTP server level.
-
-**Configuration:**
-
-```crystal
-Authly.configure do |config|
- # Enable certificate-bound tokens
- config.require_certificate_bound_tokens = true
-
- # Optional: Specify trusted CA certificates for validation
- config.trusted_ca_certificates = [
- File.read("path/to/ca1.pem"),
- File.read("path/to/ca2.pem")
- ]
-end
-```
-
-**How It Works:**
-
-1. Client presents a certificate during the TLS handshake
-2. Authly calculates the SHA-256 thumbprint of the certificate
-3. The thumbprint is embedded in the access token's `cnf` (confirmation) claim
-4. When using the token, the client must present the same certificate
-5. Authly validates that the certificate matches the bound token
-
-**Token Structure (JWT):**
-
-```json
-{
- "sub": "user_id",
- "iss": "https://auth.example.com",
- "exp": 1640000000,
- "cnf": {
- "x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2"
- }
-}
-```
-
-**Server Configuration:**
-
-For mTLS to work, your HTTP server must be configured to request client certificates. With Crystal's HTTP::Server and a reverse proxy like nginx:
-
-```nginx
-server {
- listen 443 ssl;
- ssl_client_certificate /path/to/ca.pem;
- ssl_verify_client optional;
- ssl_verify_depth 2;
-
- location / {
- proxy_pass http://authly_backend;
- proxy_set_header X-Client-Certificate $ssl_client_cert;
- }
-}
-```
-
-**Benefits:**
-
-- Prevents token theft - stolen tokens cannot be used without the certificate
-- Suitable for high-security environments (banking, healthcare, government)
-- Compliant with FAPI (Financial-grade API) security profile
-
-### JWT Secured Authorization Response Mode (JARM)
-
-JARM (RFC 9101) protects authorization responses by encoding them in signed and optionally encrypted JWTs. This prevents response tampering, injection attacks, and ensures the integrity and authenticity of authorization responses.
-
-**Configuration:**
-
-```crystal
-Authly.configure do |config|
- # JARM signing configuration
- config.jarm_signing_key = ENV["JARM_SIGNING_KEY"]
- config.jarm_signing_alg = JWT::Algorithm::RS256
-
- # Optional: JARM encryption configuration
- config.jarm_encryption_key = ENV["JARM_ENCRYPTION_KEY"]
- config.jarm_encryption_alg = "RSA-OAEP"
- config.jarm_encryption_enc = "A256GCM"
-
- # Optional: Default JARM response mode
- config.default_jarm_response_mode = "query.jwt"
-
- # JARM JWT lifetime (default: 10 minutes)
- config.jarm_lifetime = 10.minutes
-end
-```
-
-**How It Works:**
-
-When JARM is enabled, instead of returning authorization parameters in the query string or fragment:
-
-```
-https://client.example.com/callback?code=AUTH_CODE&state=xyz
-```
-
-The authorization server returns a signed JWT containing the response:
-
-```
-https://client.example.com/callback?response=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
-```
-
-The JWT payload contains:
-
-```json
-{
- "iss": "https://auth.example.com",
- "aud": "client_id",
- "exp": 1640000600,
- "code": "AUTH_CODE",
- "state": "xyz"
-}
-```
-
-**Response Modes:**
-
-JARM supports multiple response modes:
-
-1. **query.jwt** - JWT in query parameter `response`
-
- ```
- GET /callback?response=
- ```
-
-2. **fragment.jwt** - JWT in fragment parameter `response`
-
- ```
- GET /callback#response=
- ```
-
-3. **form_post.jwt** - Auto-submitting HTML form with JWT
-
- ```html
-
- ```
-
-4. **jwt** - Direct JWT response (for JAR flows)
-
- ```
- HTTP/1.1 200 OK
- Content-Type: application/jwt
-
-
- ```
-
-**Per-Client Configuration:**
-
-Clients can request JARM during dynamic registration:
-
-```json
-{
- "redirect_uris": ["https://client.example.com/callback"],
- "authorization_signed_response_alg": "RS256",
- "authorization_encrypted_response_alg": "RSA-OAEP",
- "authorization_encrypted_response_enc": "A256GCM"
-}
-```
-
-Or configure it statically:
-
-```crystal
-client = Authly::Client.new(
- name: "Secure Client",
- secret: "client_secret",
- redirect_uri: "https://client.example.com/callback",
- id: "client_123",
- jarm_signing_alg: "RS256",
- jarm_encryption_alg: "RSA-OAEP",
- jarm_encryption_enc: "A256GCM"
-)
-```
-
-**Usage Example:**
-
-Client requests authorization with JARM:
-
-```http
-GET /oauth/authorize?
- response_type=code&
- client_id=CLIENT_ID&
- redirect_uri=REDIRECT_URI&
- response_mode=query.jwt&
- state=STATE
-```
-
-Authorization server redirects with JARM response:
-
-```http
-HTTP/1.1 302 Found
-Location: https://client.example.com/callback?response=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
-```
-
-Client decodes and validates the JWT:
-
-```crystal
-# Extract JWT from response parameter
-jwt = params["response"]
-
-# Verify signature and decode
-payload, header = JWT.decode(jwt, server_public_key, JWT::Algorithm::RS256,
- iss: "https://auth.example.com",
- aud: client_id
-)
-
-# Extract authorization code
-authorization_code = payload["code"]
-state = payload["state"]
-```
-
-**Supported Algorithms:**
-
-**Signing Algorithms (JWS):**
-
-- RS256, RS384, RS512 (RSA with SHA)
-- ES256, ES384, ES512 (ECDSA with SHA)
-- HS256, HS384, HS512 (HMAC with SHA)
-- PS256, PS384, PS512 (RSA PSS with SHA)
-
-**Encryption Algorithms (JWE - Key Encryption):**
-
-- RSA-OAEP, RSA-OAEP-256
-- ECDH-ES, ECDH-ES+A128KW, ECDH-ES+A192KW, ECDH-ES+A256KW
-
-**Encryption Encodings (JWE - Content Encryption):**
-
-- A128GCM, A192GCM, A256GCM
-- A128CBC-HS256, A192CBC-HS384, A256CBC-HS512
-
-**Benefits:**
-
-- **Response Integrity**: Prevents tampering with authorization responses
-- **Response Authentication**: Verifies the authorization server issued the response
-- **Confidentiality**: Optional encryption protects sensitive response data
-- **Injection Prevention**: Prevents authorization response injection attacks
-- **FAPI Compliance**: Required for Financial-grade API Advanced security profile
-
-**Security Considerations:**
-
-- JARM JWTs should have short lifetimes (default: 10 minutes)
-- Clients must validate the `iss` (issuer) and `aud` (audience) claims
-- Clients must verify JWT signatures using the authorization server's public key
-- Use RS256 or ES256 for production deployments (asymmetric algorithms)
-- Enable encryption for highly sensitive authorization responses
-
-## Customization
-
-Authly provides extension points for integrating with your application's authentication logic:
-
-### Custom Authorizable Client
-
-Implement the `AuthorizableClient` module to define client authentication and validation:
-
-```crystal
-class CustomAuthorizableClient
- include Authly::AuthorizableClient
-
- def valid_redirect?(redirect_uri : String) : Bool
- # Validate redirect URI against allowed URLs
- # This should query your database or configuration
- allowed_uris = [
- "https://your-app.com/callback",
- "https://your-app.com/oauth/callback"
- ]
- allowed_uris.includes?(redirect_uri)
- end
-
- def authorized?(client_id : String, client_secret : String) : Bool
- # Verify client credentials
- # This should query your database or authentication service
- client = Client.find_by_id(client_id)
- client && client.secret == client_secret && client.active?
- end
-end
-```
-
-### Custom Authorizable Owner
-
-Implement the `AuthorizableOwner` module to define resource owner authentication:
-
-```crystal
-class CustomAuthorizableOwner
- include Authly::AuthorizableOwner
-
- def authorized?(username : String, password : String) : Bool
- # Verify user credentials
- # This should use your authentication system (bcrypt, argon2, etc.)
- user = User.find_by_username(username)
- user && user.verify_password(password) && user.active?
- end
-
- def id_token(user_data : Hash(String, String)) : String
- # Generate an OpenID Connect ID Token
- # Include standard claims like sub, name, email, etc.
- user_id = user_data["user_id"]
- user = User.find(user_id)
-
- JWT.encode({
- "sub" => user.id,
- "name" => user.full_name,
- "email" => user.email,
- "email_verified" => user.email_verified?,
- "iat" => Time.utc.to_unix,
- "exp" => (Time.utc + 1.hour).to_unix
- }, Authly.configuration.secret_key, Authly.configuration.algorithm)
- end
-end
-```
-
-### Custom Token Store
-
-For production deployments, implement a persistent token store using your preferred database:
-
-```crystal
-class CustomTokenStore
- include Authly::TokenStore
-
- def store(token : String, data : Hash(String, String))
- # Store token in database with expiration
- Token.create(
- token: token,
- data: data.to_json,
- expires_at: Time.utc + data["ttl"].to_i.seconds
- )
- end
-
- def fetch(token : String) : Hash(String, String)?
- # Fetch non-expired token from database
- token_record = Token.find_by_token(token)
- return nil unless token_record && !token_record.expired?
-
- JSON.parse(token_record.data).as(Hash(String, String))
- rescue
- nil
- end
-
- def revoke(token : String)
- # Mark token as revoked
- Token.where(token: token).update_all(revoked: true)
- end
-
- def revoked?(token : String) : Bool
- # Check if token has been revoked
- token_record = Token.find_by_token(token)
- token_record.nil? ? false : token_record.revoked?
- end
-
- def valid?(token : String) : Bool
- # Verify token is not revoked and not expired
- token_record = Token.find_by_token(token)
- return false unless token_record
-
- !token_record.revoked? && !token_record.expired?
- end
-end
-```
-
-**Benefits of Custom Token Store:**
-
-- Persistence across server restarts
-- Scalability for distributed systems
-- Token audit trail and analytics
-- Advanced token management features
-
-## Token Strategies
-
-Authly supports two token strategies:
-
-### JWT Tokens (Recommended)
-
-**Advantages:**
-
-- Self-contained and stateless
-- No database lookup required for validation
-- Contains user information and metadata
-- Industry-standard format
-
-**Configuration:**
-
-```crystal
-Authly.configure do |config|
config.token_strategy = :jwt
- config.algorithm = JWT::Algorithm::HS256 # or RS256, ES256, etc.
- config.secret_key = ENV["JWT_SECRET_KEY"]
end
-```
-
-**Supported Algorithms:**
-
-- HS256, HS384, HS512 (HMAC with SHA)
-- RS256, RS384, RS512 (RSA with SHA)
-- ES256, ES384, ES512 (ECDSA with SHA)
-
-### Opaque Tokens
-
-**Advantages:**
-
-- Revocable without database lookups
-- No sensitive data exposure
-- Smaller token size
-
-**Configuration:**
-
-```crystal
-Authly.configure do |config|
- config.token_strategy = :opaque
- config.token_store = CustomTokenStore.new # Required for opaque tokens
-end
-```
-
-## Protecting Your API
-
-`Authly::Handler` is the authorization server. The handlers below are for the **resource server**: the
-routes that accept access tokens. They are plain `HTTP::Handler`s with no web framework behind them, so
-they go into any handler chain.
-
-### Bearer Handler
-`Authly::Handler::Bearer` reads `Authorization: Bearer `, verifies it with Authly's own
-configuration and hands the verified claims to the handlers after it:
-
-```crystal
-require "authly"
-
-server = HTTP::Server.new([
- Authly::Handler::Bearer.new(realm: "api", except_exact: ["/health"]),
- MyApp.new,
-])
-```
-
-`Bearer` protects **every** request except the paths in `except` (see below). Downstream, the claims are
-`context.authly_claims` (an `Authly::Claims`, `nil` where the handler did not apply) or
-`context.authly_claims!`, which raises `Authly::MissingClaims` instead of returning nil. They are an instance variable of that request's own `HTTP::Server::Context`, so concurrent
-requests never see each other's claims and nothing global has to be cleaned up:
-
-```crystal
-class MyApp
- include HTTP::Handler
+# Register a client for machine-to-machine authentication
+Authly.clients << Authly::Client.new(
+ name: "Billing Service",
+ secret: "billing-secret-123",
+ redirect_uri: "http://localhost:8080/callback",
+ id: "billing-service",
+ scopes: "invoices:read invoices:write",
+ allowed_grant_types: ["client_credentials"]
+)
- def call(context)
+app = HTTP::Handler::HandlerProc.new do |context|
+ case context.request.path
+ when "/health"
+ context.response.content_type = "application/json"
+ context.response.print({status: "ok"}.to_json)
+ when "/api/invoices"
claims = context.authly_claims!
- claims.subject # "sub"
- claims.client_id # "cid"
- claims.scopes # ["read:users", "write:users"]
- claims.scope?("read:users")
- claims["exp"].as_i64 # any other claim, as JSON::Any
+ context.response.content_type = "application/json"
+ context.response.print({
+ client_id: claims.client_id,
+ scopes: claims.scopes,
+ invoices: [{id: "inv_01", amount_cents: 4500}],
+ }.to_json)
+ else
+ context.response.status = HTTP::Status::NOT_FOUND
end
end
-```
-What is verified: the signature with the configured `algorithm` (the token's own `alg` header is never
-trusted, so `alg: none` and key-confusion tokens fail), `exp` (required), `nbf`, the issuer, and
-revocation. Only **access tokens** pass: they carry `token_type: "access_token"`, and a refresh token (same
-key, other type) is refused. Both `:jwt` and `:opaque` token strategies work. The token is decoded once per
-request. Tokens issued before `token_type` existed (up to `access_ttl` old after an upgrade) are accepted
-only with `StrategyTokenVerifier.new(legacy_tokens: true)`, for the length of that rollout.
-
-**Set an audience.** Access tokens carry `aud` (`config.access_token_audience`, `base_url` by default). A
-resource server that does not check it accepts a token minted for *any* API that shares the key (a confused
-deputy). Give each API its own audience and check it:
-
-```crystal
-# on the authorization server
-config.access_token_audience = "https://api.example.com"
+server = HTTP::Server.new([
+ Authly::Handler.new,
+ Authly::Handler::Bearer.new(
+ realm: "api",
+ except_exact: ["/health"],
+ except: ["/oauth", "/.well-known"]
+ ),
+ Authly::Handler::RequireScope.all("invoices:read", prefixes: ["/api/invoices"]),
+ app,
+] of HTTP::Handler)
-# on the resource server
-verifier = Authly::StrategyTokenVerifier.new(audience: "https://api.example.com")
-Authly::Handler::Bearer.new(verifier, realm: "api", except_exact: ["/health"])
+address = server.bind_tcp("127.0.0.1", 8080)
+puts "Listening on http://#{address}"
+server.listen
```
-Without `audience:` any `aud` is accepted. Authly issues one audience per server, not per request
-(no RFC 8707 resource indicators), so use one authorization server per audience if APIs must be isolated.
-
-Anything else (remote introspection, another key source) is an `Authly::TokenVerifier`: implement
-`verify(token : String) : Authly::Claims?` and pass it as the first argument.
-
-Failures follow RFC 6750 and never echo the token or log it:
-
-| Request | Status | `WWW-Authenticate` |
-| ------------------------------------------ | ------ | ------------------------------------------------ |
-| No credentials (or another scheme) | 401 | `Bearer realm="api"` |
-| Malformed header, two headers, empty token | 400 | `Bearer realm="api", error="invalid_request"` |
-| Bad, expired, revoked, wrong-key token | 401 | `Bearer realm="api", error="invalid_token"` |
-| Valid token without the required scope | 403 | `... error="insufficient_scope", scope="a b"` |
-| Token store unreachable (Redis down) | 503 | none; `Retry-After: 1` |
-
-A store outage is a 503, not a 401. A `TokenStore` signals it by raising `Authly::StoreUnavailable` (an `IO::Error` counts too);
-it raises `Authly::Error` from `fetch` for an unknown token, and any other exception is read as "not a valid token" (401), so a
-store that raises `KeyError` for an unknown token cannot be used to make the server answer 503. A 401 would send every client off to re-authenticate: a 401 would send every client off to re-authenticate. It still fails
-closed (nobody gets in).
+Issue a token and call the protected API:
-The scheme is case-insensitive (`bearer`, `BEARER`); a tab is not a separator. The token is read from the
-header only. Set `allow_query_token: true` to also accept `?access_token=` (one value only; a header as well
-is refused). RFC 6750 discourages it: URLs end up in access logs, referrers and browser history, so
-make sure your logs do not record query strings. Such responses get `Cache-Control: private` and
-`Referrer-Policy: no-referrer`.
-
-**Which paths.** Everything is protected except what you list, so a route added later is protected by
-default. `except_exact: ["/health"]` exempts just that path (`/health` and `/health/`, not `/health/details`):
-use it for health checks. `except: ["/oauth"]` is a **segment-prefix** match, case-sensitive: it exempts
-`/oauth` **and everything below it**, not `/oauthx`. `prefixes: ["/api"]` is the opposite, an
-**allowlist: every path you do not list is open**. A forgotten route, a new top-level path or a router that
-matches differently is then unprotected, so prefer the other two. Paths are compared after percent-decoding
-(once, like a router), collapsing `//` and resolving `.`/`..`, so `/%61pi/x`, `//api/x` and `/x/../api/x` are
-`/api/x`. A path that still has a `%` after decoding (double encoding, `%zz`) or a NUL byte is not
-understood, and is protected. `prefixes` ignores case (`/API/x` is protected), `except`/`except_exact` do not
-(`/HEALTH` is not exempted by `/health`). A trailing slash does not matter, `prefixes: []` is refused, and an
-entry with encoded characters is refused.
-
-**CORS.** Put the CORS handler **before** `Bearer`, or a browser cannot read the 401. A preflight
-(`OPTIONS` with `Access-Control-Request-Method`) carries no credentials: either let the CORS handler
-answer it, or give `Bearer` **and `RequireScope`** `pass_preflight: true` to hand it to the next handler. Off
-by default. A preflight is recognised by headers anyone can send, so what answers `OPTIONS` behind this flag is
-**public** and must have no side effects.
-
-### Requiring Scopes
-
-`Authly::Handler::RequireScope` goes after `Bearer`. `all` needs every scope listed, `any` at least one:
+```bash
+# 1. Request an access token
+TOKEN=$(curl -s -X POST http://127.0.0.1:8080/oauth/token \
+ -u "billing-service:billing-secret-123" \
+ -d "grant_type=client_credentials&scope=invoices:read" | jq -r .access_token)
-```crystal
-HTTP::Server.new([
- Authly::Handler::Bearer.new(except_exact: ["/health"]),
- Authly::Handler::RequireScope.all("read:users", prefixes: ["/users"]),
- Authly::Handler::RequireScope.any("admin", "root", prefixes: ["/admin"]),
- MyApp.new,
-])
+# 2. Call the protected endpoint
+curl -i http://127.0.0.1:8080/api/invoices \
+ -H "Authorization: Bearer $TOKEN"
```
-`RequireScope.new(all: [...], any: [...])` needs both. It takes `prefixes:`, `except:`, `except_exact:` and `pass_preflight:` like `Bearer` and applies to
-every path unless narrowed, so repeat the `except` paths of `Bearer` if it is used without `prefixes`. A request that lacks a scope gets
-`403` with `error="insufficient_scope"` and the scopes it needs; a request that no `Bearer` authenticated
-gets `401`, it is never let through. Scope names and realms are checked when the handler is built, so a
-value cannot inject into the challenge header.
-
-### JWKS and Metadata
-
-`GET /.well-known/jwks.json` serves the key set tokens are verified with, and
-`GET /.well-known/oauth-authorization-server` (RFC 8414) serves the same metadata as
-`/.well-known/openid-configuration`. Both are routes of `Authly::Handler`; `Authly::JWKS.new.to_json`
-builds the document without a server.
+---
-A key set only makes sense for **asymmetric** algorithms. Authly signs with HS256 by default, where the
-verification key is the signing secret and must never be published, so the set is empty (`{"keys":[]}`).
-To publish a key, sign with RS256/RS384/RS512 or ES256/ES384/ES512 and set the public half as a PEM:
+## Documentation
+
+Full documentation is available in [`docs/`](docs/README.md):
+
+### [Tutorials](docs/tutorials/README.md)
+Step-by-step guides that walk you through building working applications from scratch:
+- **[Getting Started: Your First OAuth2 Server & Protected API](docs/tutorials/getting-started.md)** — Launch an OAuth2 server, issue a `client_credentials` token, and protect an endpoint.
+- **[Interactive User Sign-In with Authorization Code, PKCE & OIDC](docs/tutorials/authorization-code-pkce.md)** — Connect host user sessions, render consent, verify `S256` PKCE, and fetch UserInfo.
+- **[Passwordless Authentication with Challenge-Response](docs/tutorials/challenge-response-auth.md)** — Authenticate clients and users with Ed25519 or HMAC-SHA256 challenge nonces.
+
+### [How-To Guides](docs/how-to/README.md)
+Practical recipes for configuring production features:
+- **[Implement Custom Clients, Owners & Persistent Redis Stores](docs/how-to/custom-stores-and-models.md)** — Wire `AuthorizableClient`, `AuthorizableOwner`, `TokenStore`, `ChallengeStore`, `StateStore`, and `AttemptLimiter` to SQL and Redis.
+- **[Protect Resource APIs & Mount in Azu](docs/how-to/protect-resource-apis.md)** — Configure `Authly::Handler::Bearer`, `Authly::Handler::RequireScope`, audience validation, CORS preflights, and Azu middleware chains.
+- **[Enable FAPI & Advanced Security (PKCE, PAR, JARM, mTLS)](docs/how-to/fapi-and-advanced-security.md)** — Enforce `S256` PKCE, Pushed Authorization Requests (`POST /oauth/par`), JARM signed/encrypted responses, and certificate-bound tokens.
+- **[Configure Asymmetric Keys, Key Rotation & JWKS](docs/how-to/key-rotation-and-jwks.md)** — Sign with `RS256` / `ES256`, rotate keys without downtime via `verification_keys`, and customize claims with `IssuanceOptions`.
+- **[Configure Dynamic Client Registration & Device Authorization](docs/how-to/dynamic-client-registration.md)** — Enable RFC 7591 registration (`POST /oauth/register`) and RFC 8628 smart-TV/CLI device flows (`POST /oauth/device/code` & `/device`).
+
+### [Reference](docs/reference/README.md)
+Complete technical specifications:
+- **[`Authly::Configuration` Reference](docs/reference/configuration.md)** — Every setting, TTL, store, security flag, and callback on `Authly.config`.
+- **[HTTP Endpoints & Wire Protocol](docs/reference/http-endpoints.md)** — Request parameters, headers, and JSON responses for all endpoints.
+- **[Crystal API, Grants & Store Interfaces](docs/reference/grants-and-interfaces.md)** — Signatures for `Authly`, `AuthorizableClient`, `AuthorizableOwner`, `ChallengeVerifier`, `Claims`, and all stores.
+- **[Errors & RFC 6750 Challenge Responses](docs/reference/errors.md)** — `Authly::Error` codes, HTTP statuses, and `WWW-Authenticate` error responses.
+
+### [Explanation](docs/explanation/README.md)
+Architectural overviews and security design:
+- **[Architecture & Design Principles](docs/explanation/architecture.md)** — How Authly separates the Authorization Server, Resource Server, host login session, and stores.
+- **[Token Strategies & Refresh Token Lifecycle](docs/explanation/token-strategies-and-lifecycle.md)** — `:jwt` vs. `:opaque` trade-offs, token-type separation, and refresh token rotation with family theft detection.
+- **[Security Model & Multi-Node Deployment](docs/explanation/security-model.md)** — Atomic `consume` guarantees, constant-time challenge verification, fail-closed rate limiting, and multi-node checklists.
-```crystal
-Authly.configure do |config|
- config.algorithm = JWT::Algorithm::RS256
- config.secret_key = File.read("private.pem")
- config.public_key = File.read("public.pem")
-end
-```
+---
-The key is published with `use`, `alg` and a `kid` (its RFC 7638 thumbprint). Authly does not put `kid`
-in the tokens it issues and publishes one key, so rotation (several keys at once) is not supported yet.
-The algorithm has to fit the key (RS* an RSA key, ES256/384/512 an EC key on P-256/P-384/P-521). A private key, a key that does not fit, or anything that is not a PEM public key, is refused (the endpoint answers 500 with `Cache-Control: no-store`) instead of
-being published; a published set is `public, max-age=300`. EdDSA, PS* and ES256K are not published.
+## HTTP Endpoints Overview
+
+`Authly::Handler` mounts the following endpoints out of the box:
+
+| Method(s) | Path | Purpose |
+| :--- | :--- | :--- |
+| `GET`, `POST` | `/oauth/authorize` | Interactive authorization & user consent (RFC 6749, OIDC, PKCE, PAR, JARM) |
+| `POST` | `/oauth/token` | Token issuance & refresh (`authorization_code`, `client_credentials`, `refresh_token`, `password`, `device_code`, `challenge_response`) |
+| `POST` | `/oauth/par` | Pushed Authorization Requests (RFC 9126) |
+| `POST` | `/oauth/challenge` | Cryptographic Challenge-Response nonce issuance |
+| `POST` | `/oauth/device/code` | Device Authorization code issuance (RFC 8628) |
+| `GET`, `POST` | `/device` | End-user device code verification page (RFC 8628) |
+| `POST` | `/oauth/introspect`, `/introspect` | Token Introspection (RFC 7662) |
+| `POST` | `/oauth/revoke`, `/revoke` | Token Revocation (RFC 7009) |
+| `GET` | `/oauth/userinfo` | OpenID Connect UserInfo claims |
+| `POST` | `/oauth/register` | Dynamic Client Registration (RFC 7591) |
+| `GET`, `HEAD` | `/.well-known/openid-configuration` | OpenID Connect Discovery 1.0 metadata |
+| `GET`, `HEAD` | `/.well-known/oauth-authorization-server` | OAuth 2.0 Authorization Server Metadata (RFC 8414) |
+| `GET`, `HEAD` | `/.well-known/jwks.json` | JSON Web Key Set (RFC 7517) |
-The metadata's `issuer` is `base_url`, while tokens carry `iss` = `config.issuer`. Set both to the same
-URL if clients validate the metadata issuer against the token.
+---
-A resource server that is a separate process can verify the same tokens by configuring the public key
-(`algorithm` and `public_key`) and using `Bearer`; revocation is only seen where the token store is shared.
+## Development & Contributing
-### Mounting in an Azu App
+Contributions are welcome! To work on Authly locally:
-Authly needs no Azu, and Azu is not a dependency of it. In an [Azu](https://github.com/azutoolkit/azu)
-app, `Azu::App#server` takes the handler chain, and every `HTTP::Handler` is routable, so put the
-handlers after the app's default ones:
+```bash
+# Install dependencies
+shards install
-```crystal
-require "azu"
-require "authly"
+# Run the test suite
+crystal spec
-app = Azu::App.default
-app.router.get "/users", UsersEndpoint.new
+# Check formatting
+crystal tool format --check src spec
-server = app.server([
- *app.default_handlers,
- Authly::Handler.new,
- Authly::Handler::Bearer.new(except_exact: ["/health"], except: ["/oauth"]),
- Authly::Handler::RequireScope.all("read:users", prefixes: ["/users"]),
-] of HTTP::Handler)
+# Run static code analysis
+crystal run lib/ameba/src/cli.cr
```
-An endpoint reads `context.authly_claims`. **This snippet is unchecked**: Azu is not a dependency of Authly,
-so CI does not compile it (it was written against Azu master's `App#server` and `default_handlers`). The
-Authly specs run the same handler chain over a real `HTTP::Server`.
-
-## Roadmap
+1. Fork the repository (`https://github.com/azutoolkit/authly/fork`)
+2. Create your feature branch (`git checkout -b feat/my-feature`)
+3. Write specs covering your changes in `spec/`
+4. Ensure `crystal spec`, `crystal tool format --check src spec`, and `ameba` pass
+5. Open a Pull Request
-### Completed Features ✅
-
-- [x] OAuth2 Dynamic Client Registration (RFC 7591)
-- [x] OpenID Connect UserInfo endpoint
-- [x] OpenID Connect Discovery endpoint
-- [x] Certificate-bound access tokens (mTLS / RFC 8705)
-- [x] Enhanced PKCE implementation with S256 method enforcement
-- [x] Pushed Authorization Requests (PAR / RFC 9126)
-- [x] JWT Secured Authorization Response Mode (JARM / RFC 9101)
-
-### Planned Enhancements
-
-- [ ] Rate limiting and throttling for authentication endpoints
-- [ ] Session management capabilities
-- [ ] Additional token storage backends (Redis, Memcached)
-- [x] JWKS endpoint and RFC 8414 metadata (asymmetric algorithms only)
-- [ ] JWKS key rotation (several published keys)
-- [ ] Comprehensive integration examples
-- [ ] Admin UI for client and token management
-- [ ] Detailed logging and monitoring hooks
-
-See the [open issues](https://github.com/azutoolkit/authly/issues) for a list of proposed features and known issues.
-
-## Contributing
-
-Contributions are what make the open-source community an amazing place to learn, inspire, and create. We welcome contributions of all kinds!
-
-### How to Contribute
-
-1. **Fork the Project** - Create your own fork of the repository
-2. **Create a Feature Branch** - `git checkout -b feature/amazing-feature`
-3. **Make Your Changes** - Implement your feature or bug fix
-4. **Write Tests** - Ensure your changes are covered by tests
-5. **Run the Test Suite** - `crystal spec` to verify all tests pass
-6. **Run the Linter** - `bin/ameba` to ensure code quality
-7. **Commit Your Changes** - `git commit -m 'Add some amazing feature'`
-8. **Push to Your Branch** - `git push origin feature/amazing-feature`
-9. **Open a Pull Request** - Submit your PR with a clear description
-
-### Code Style
-
-- Follow Crystal's official style guide
-- Write clear, descriptive commit messages
-- Add documentation for new features
-- Include specs for all new functionality
-
-### Reporting Issues
-
-Found a bug or have a feature request? Please open an issue on GitHub with:
-
-- A clear, descriptive title
-- Detailed description of the issue or feature
-- Steps to reproduce (for bugs)
-- Expected vs actual behavior
-- Environment details (Crystal version, OS, etc.)
+---
## License
-Distributed under the MIT License. See `LICENSE` for more information.
+Distributed under the MIT License. See [`LICENSE`](LICENSE) for details.
## Contact
-**Elias J. Perez**
-
-- GitHub: [@eliasjpr](https://github.com/eliasjpr)
-- Project Link: [https://github.com/azutoolkit/authly](https://github.com/azutoolkit/authly)
-
----
-
-
- Made with ❤️ by the Crystal community
-
-
-### Host-controlled issuance and signing-key rotation
-
-`Authly::IssuanceOptions` lets trusted application code set a resource audience,
-absolute expiration, and scalar application claims without changing global
-configuration between requests. Reserved JWT fields cannot be overridden.
-
-```crystal
-options = Authly::IssuanceOptions.new(
- "https://api.example.test", Time.utc + 5.minutes,
- {"sid" => "session-id"} of String => Authly::AdditionalClaim)
-token = Authly::AccessToken.new("client-id", "read", subject: "account:42", options: options)
-```
-
-Explicit options default to no refresh token. Existing issuance retains refresh
-behavior unless `config.issue_refresh_tokens = false`; `refresh_token` is now
-nullable and omitted from JSON when disabled. Set `config.grant_types` to the
-allowed grants as well; disabling issuance alone does not disable redemption.
-`config.issuance_options` is an optional trusted `(client_id, grant_type)` callback
-for token-endpoint issuance. Never populate its claims from unverified form data.
-
-For asymmetric rotation, set `signing_key_id`, the active private `secret_key`,
-and `verification_keys` (a kid-to-public-PEM map containing active and retiring
-keys). JWTs carry the active kid; verification rejects absent or unknown kids when
-the map is configured. JWKS publishes only those public keys. Keep retiring keys
-until every credential signed with them expires.
-
-`config.render_consent` receives the HTTP context, validated authorization request,
-verified subject, and signed consent token. It may render the host UI, but approval
-must POST the original token and decision to `/oauth/authorize`; it cannot bypass
-subject binding or single-use CSRF protection. OIDC nonce is kept separate from
-consent state and is preserved in the ID token.
-
-`config.userinfo` resolves a `Hash(String, JSON::Any)` from the verified subject,
-including boolean claims such as `email_verified`. The resolver cannot replace
-the authenticated subject or granted scopes. Account lifecycle, session validity,
-resource policy, shared-store atomicity, and browser protection remain host duties.
+**Elias J. Perez** — [@eliasjpr](https://github.com/eliasjpr) · [https://github.com/azutoolkit/authly](https://github.com/azutoolkit/authly)
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..382b336
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,87 @@
+---
+description: Production-ready OAuth 2.0, OpenID Connect, and FAPI authentication library for Crystal.
+icon: shield-halved
+---
+
+# Welcome to Authly
+
+**Authly** is a strongly-typed, zero-framework Crystal shard for building **OAuth 2.0**, **OpenID Connect (OIDC)**, and **Financial-grade API (FAPI)** Authorization Servers and Resource Servers on top of Crystal's standard `HTTP::Handler`.
+
+---
+
+## Documentation Sections
+
+
+
+
+ |
+ |
+ |
+
+
+
+
+| 🎓 Tutorials |
+Step-by-step guided lessons that take you from a blank Crystal project to a working OAuth2 server and protected API. |
+tutorials/README.md |
+
+
+| 🛠️ How-To Guides |
+Goal-oriented recipes for Redis stores, scope enforcement, PAR, JARM, mTLS, key rotation, and Dynamic Client Registration. |
+how-to/README.md |
+
+
+| 📖 Reference |
+Complete technical specifications for Authly::Configuration, HTTP endpoints, Crystal interfaces, and error codes. |
+reference/README.md |
+
+
+| 💡 Explanation |
+Deep dives into Authly's architecture, refresh token rotation families, atomic single-use credentials, and threat model. |
+explanation/README.md |
+
+
+
+
+---
+
+## Quick Installation
+
+{% tabs %}
+{% tab title="shard.yml" %}
+```yaml title="shard.yml"
+dependencies:
+ authly:
+ github: azutoolkit/authly
+```
+{% endtab %}
+
+{% tab title="Terminal" %}
+```bash
+shards install
+```
+{% endtab %}
+{% endtabs %}
+
+---
+
+## Capabilities at a Glance
+
+| Category | Supported Specifications & Features |
+| :--- | :--- |
+| **OAuth 2.0 Grants** | Authorization Code (`authorization_code`), PKCE (`S256` / `plain`), Client Credentials (`client_credentials`), Refresh Token (`refresh_token`), Device Authorization (`urn:ietf:params:oauth:grant-type:device_code`), Cryptographic Challenge-Response (`challenge_response` / `urn:ietf:params:oauth:grant-type:challenge`), Password (`password`), Implicit (`implicit`) |
+| **OpenID Connect** | ID Tokens, UserInfo Endpoint (`/oauth/userinfo`), OIDC Discovery (`/.well-known/openid-configuration`), RFC 8414 Metadata (`/.well-known/oauth-authorization-server`), JWKS (`/.well-known/jwks.json`) |
+| **FAPI & Hardening** | Pushed Authorization Requests (PAR / RFC 9126), JWT Secured Authorization Response Mode (JARM / RFC 9101), Certificate-Bound mTLS Tokens (`cnf.x5t#S256` / RFC 8705), Dynamic Client Registration (RFC 7591) |
+| **Resource Protection** | `Authly::Handler::Bearer` (RFC 6750), `Authly::Handler::RequireScope` (`all` / `any`), Audience (`aud`) verification, JWT & Opaque token strategies |
+
+---
+
+## Start Building
+
+{% content-ref url="tutorials/getting-started.md" %}
+[getting-started.md](tutorials/getting-started.md)
+{% endcontent-ref %}
+
+{% content-ref url="tutorials/challenge-response-auth.md" %}
+[challenge-response-auth.md](tutorials/challenge-response-auth.md)
+{% endcontent-ref %}
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
new file mode 100644
index 0000000..f66d01d
--- /dev/null
+++ b/docs/SUMMARY.md
@@ -0,0 +1,34 @@
+# Table of contents
+
+* [Welcome to Authly](README.md)
+
+## Tutorials
+
+* [Overview](tutorials/README.md)
+ * [Getting Started: First OAuth2 Server & API](tutorials/getting-started.md)
+ * [Interactive Sign-In with PKCE & OIDC](tutorials/authorization-code-pkce.md)
+ * [Cryptographic Challenge-Response Auth](tutorials/challenge-response-auth.md)
+
+## How-To Guides
+
+* [Overview](how-to/README.md)
+ * [Implement Custom Clients, Owners & Redis Stores](how-to/custom-stores-and-models.md)
+ * [Protect Resource APIs & Mount in Azu](how-to/protect-resource-apis.md)
+ * [Enable FAPI & Advanced Security (PKCE, PAR, JARM, mTLS)](how-to/fapi-and-advanced-security.md)
+ * [Configure Asymmetric Keys, Key Rotation & JWKS](how-to/key-rotation-and-jwks.md)
+ * [Configure Dynamic Client Registration & Device Flow](how-to/dynamic-client-registration.md)
+
+## Reference
+
+* [Overview](reference/README.md)
+ * [Configuration Reference](reference/configuration.md)
+ * [HTTP Endpoints & Wire Protocol](reference/http-endpoints.md)
+ * [Crystal API, Grants & Store Interfaces](reference/grants-and-interfaces.md)
+ * [Errors & RFC 6750 Challenge Responses](reference/errors.md)
+
+## Explanation
+
+* [Overview](explanation/README.md)
+ * [Architecture & Design Principles](explanation/architecture.md)
+ * [Token Strategies & Refresh Token Lifecycle](explanation/token-strategies-and-lifecycle.md)
+ * [Security Model & Multi-Node Deployment](explanation/security-model.md)
diff --git a/docs/explanation/README.md b/docs/explanation/README.md
new file mode 100644
index 0000000..13d9af2
--- /dev/null
+++ b/docs/explanation/README.md
@@ -0,0 +1,20 @@
+---
+description: Architectural deep-dives into Authly's design principles, token lifecycle, and security model.
+icon: lightbulb
+---
+
+# Explanation
+
+These pages cover how Authly is designed, how token rotation families work under the hood, and what security invariants you must maintain when deploying across multiple nodes.
+
+{% content-ref url="architecture.md" %}
+[architecture.md](architecture.md)
+{% endcontent-ref %}
+
+{% content-ref url="token-strategies-and-lifecycle.md" %}
+[token-strategies-and-lifecycle.md](token-strategies-and-lifecycle.md)
+{% endcontent-ref %}
+
+{% content-ref url="security-model.md" %}
+[security-model.md](security-model.md)
+{% endcontent-ref %}
diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md
new file mode 100644
index 0000000..502f413
--- /dev/null
+++ b/docs/explanation/architecture.md
@@ -0,0 +1,61 @@
+---
+description: How Authly separates the Authorization Server, Resource Server, host user authentication, and storage interfaces.
+icon: sitemap
+---
+
+# Architecture & Design Principles
+
+Authly is designed as a composable, zero-framework Crystal OAuth2 and OpenID Connect shard built directly on Crystal's standard `HTTP::Handler`.
+
+---
+
+## 1. Separation of Authorization Server and Resource Server
+
+Authly separates **issuing credentials** from **verifying credentials on API requests**:
+
+```mermaid
+flowchart TD
+ Req["Incoming HTTP Request"] --> Handler["Authly::Handler (Authorization Server)"]
+ Handler -->|"/oauth/* or /.well-known/*"| OAuthEndpoints["Authorize / Token / PAR / Challenge / Introspect / Revoke / Discovery"]
+ Handler -->|"All other paths"| Bearer["Authly::Handler::Bearer (Resource Server)"]
+ Bearer -->|"Verifies Bearer Token & populates context.authly_claims"| Scope["Authly::Handler::RequireScope"]
+ Scope -->|"Enforces route-level scopes"| App["Your Application Routes"]
+```
+
+- **Co-located Deployment**: Mount `Authly::Handler`, `Authly::Handler::Bearer`, and `Authly::Handler::RequireScope` in the same `HTTP::Server` (exempting `/oauth` and `/.well-known` on `Bearer`).
+- **Separate Microservices**: Run `Authly::Handler` on a dedicated Authorization Server (`auth.example.com`) and run `Authly::Handler::Bearer` on independent Resource Servers (`api.example.com`), verifying JWTs via the published public key (`config.public_key` or `config.verification_keys`).
+
+---
+
+## 2. Why Authly Does Not Own User Login
+
+Interactive OAuth2 endpoints (`/oauth/authorize` and `/device`) act on behalf of a signed-in human user (the **resource owner**). Authly deliberately does not implement password login forms, session cookies, or MFA screens. Instead, the host application supplies `config.authenticate_user`:
+
+```crystal
+config.authenticate_user = ->(context : HTTP::Server::Context) { MySession.current_user_id(context) }
+```
+
+{% hint style="success" %}
+This boundary provides two critical security properties:
+1. **No Untrusted Subject Parameters**: Authly never reads `user_id` or `consent` from query strings or POST bodies. The authenticated owner's identity comes exclusively from `config.authenticate_user`.
+2. **Fail-Closed by Default**: Until `config.authenticate_user` is configured, `/oauth/authorize` and `/device` refuse every request rather than issuing tokens for an unverified user.
+{% endhint %}
+
+---
+
+## 3. Pluggable Store Architecture
+
+Authly isolates all state behind six focused interfaces so each concern can be backed by the right storage primitive:
+
+| Interface | What It Stores | Key Requirement |
+| :--- | :--- | :--- |
+| `TokenStore` | Opaque token payloads, revoked `jti`s, and refresh token families | Atomic `consume(jti)` and `StoreUnavailable` signaling |
+| `StateStore` | Anti-CSRF nonces for consent and device forms | Atomic single-use `consume(state)` |
+| `ChallengeStore` | Cryptographic challenge nonces (`POST /oauth/challenge`) | Atomic single-use `consume!` / `record_attempt!` |
+| `PARStore` | Pushed authorization request parameters (`request_uri`) | Atomic single-use `consume(request_uri)` |
+| `DeviceCodeStore` | Device flow `device_code` and `user_code` state | Indexed by both `device_code` and `user_code` |
+| `AttemptLimiter` | Rate-limit counters per source/owner within a sliding window | Atomic `increment(key, window)` and fail-closed capacity |
+
+{% content-ref url="../how-to/custom-stores-and-models.md" %}
+[custom-stores-and-models.md](../how-to/custom-stores-and-models.md)
+{% endcontent-ref %}
diff --git a/docs/explanation/security-model.md b/docs/explanation/security-model.md
new file mode 100644
index 0000000..c69326f
--- /dev/null
+++ b/docs/explanation/security-model.md
@@ -0,0 +1,64 @@
+---
+description: How Authly prevents TOCTOU replay attacks, timing side-channels, algorithm confusion, and resource exhaustion.
+icon: shield-check
+---
+
+# Security Model & Multi-Node Deployment
+
+Authly's security design follows **RFC 6749**, **RFC 6750**, **OAuth 2.0 Security Best Current Practice (RFC 9700)**, and **FAPI** principles.
+
+---
+
+## 1. Atomic Single-Use Credentials
+
+Several credentials in OAuth2 must be **strictly single-use**:
+- Authorization codes (`jti` in `TokenStore#consume`)
+- Refresh tokens (`jti` in `TokenStore#consume`)
+- Pushed Authorization Request URIs (`request_uri` in `PARStore#consume`)
+- Consent & Device form CSRF tokens (`state` in `StateStore#consume`)
+- Cryptographic challenges (`challenge_id` in `ChallengeStore#consume!` / `record_attempt!`)
+
+{% hint style="danger" %}
+### Why Check-Then-Delete Is Insecure
+A naive implementation that checks `if store.valid?(id)` and later calls `store.delete(id)` is vulnerable to a race condition (TOCTOU): two parallel requests presenting the same authorization code or challenge nonce can both pass `valid?` before either executes `delete`.
+{% endhint %}
+
+Authly's handlers **never** call `valid?` followed by `delete`. Instead, they rely on a single atomic operation (`consume` / `consume!` / `record_attempt!`).
+- In Authly's in-memory stores, this atomicity is enforced with a `Mutex`.
+- In multi-node Redis or SQL stores, implement `consume` as a single atomic command (e.g., Redis `SET key 1 NX EX ttl`, `DEL key` returning `1`, or `GETDEL key`).
+
+---
+
+## 2. Cryptographic Challenge-Response Hardening
+
+Authly's Challenge-Response grant (`challenge_response` / `urn:ietf:params:oauth:grant-type:challenge`) defends against credential replay, timing side-channels, and brute-force guessing:
+
+1. **High-Entropy Nonces**: Challenges are 256-bit random values generated with `Random::Secure.hex(32)` and expire after `config.challenge_ttl` (`60.seconds` by default).
+2. **Client Binding**: Each challenge is bound to the `client_id` that requested it at `POST /oauth/challenge`. Presenting a valid signature under a different `client_id` fails with `invalid_grant` and immediately burns the challenge.
+3. **Constant-Time HMAC Verification**: Symmetric challenge responses (`HMAC-SHA256`, `HMAC-SHA384`, `HMAC-SHA512`) are compared using `Crypto::Subtle.constant_time_compare` so attackers cannot infer digest bytes via response timing.
+4. **Immediate Burn on Attempt**: With the default `config.challenge_max_attempts = 1`, the challenge is removed from `ChallengeStore` on the very first verification attempt—whether the signature is valid or invalid.
+
+---
+
+## 3. Algorithm Confusion & Key Separation
+
+- **JOSE Header `alg` Is Never Trusted**: When decoding JWTs, Authly verifies signatures exclusively against `Authly.config.algorithm`. Tokens carrying `"alg": "none"` or attempting RSA-to-HMAC public-key confusion are rejected.
+- **Public Key Validation in JWKS**: `Authly::JWKS` parses DER SubjectPublicKeyInfo structures and verifies that the public key type and curve match `config.algorithm` (`RS*` requires RSA; `ES256`/`ES384`/`ES512` require P-256/P-384/P-521). Private keys are refused outright so a misconfigured `public_key` can never leak private key material at `/.well-known/jwks.json`.
+
+---
+
+## 4. Fail-Closed Rate Limiting & Bounded In-Memory Stores
+
+Endpoints reachable before authentication or with short user codes (`/oauth/device/code`, `/device`, `/oauth/challenge`, `/oauth/register`) are targets for resource exhaustion and brute-force attacks:
+- **Bounded In-Memory Stores**: `InMemoryStateStore`, `InMemoryDeviceCodeStore`, `InMemoryChallengeStore`, and `InMemoryAttemptLimiter` enforce strict `MAX_ENTRIES` caps and sweep expired entries periodically during writes.
+- **Fail-Closed Rate Limiter**: `InMemoryAttemptLimiter` never evicts a live rate-limit counter to make room for new keys (which would allow an attacker to reset their own counter by flooding the limiter from spoofed keys). If all `MAX_ENTRIES` slots are occupied by live counters, new keys return `Int32::MAX` (`FULL`), causing the endpoint to reject requests until existing windows expire.
+
+---
+
+## 5. Multi-Node Deployment Checklist
+
+When running multiple instances of an Authly server behind a load balancer:
+- [ ] Configure the exact same `config.secret_key` (and `config.public_key` / `config.verification_keys`) on every node.
+- [ ] Replace all in-memory stores (`token_store`, `state_store`, `challenge_store`, `par_store`, `device_code_store`, `client_store`) with shared Redis or database implementations.
+- [ ] Replace `attempt_limiter` and `registration_limiter` with a shared atomic Redis `AttemptLimiter`.
+- [ ] Configure `config.attempt_source` to read the client IP header overwritten by your reverse proxy (e.g., `X-Real-IP`), and ensure the application is only reachable through that proxy.
diff --git a/docs/explanation/token-strategies-and-lifecycle.md b/docs/explanation/token-strategies-and-lifecycle.md
new file mode 100644
index 0000000..0130a5a
--- /dev/null
+++ b/docs/explanation/token-strategies-and-lifecycle.md
@@ -0,0 +1,64 @@
+---
+description: Trade-offs between JWT and Opaque tokens, token-type isolation, and refresh token rotation with family replay detection.
+icon: arrows-rotate
+---
+
+# Token Strategies & Refresh Token Lifecycle
+
+Authly supports two token strategies (`:jwt` and `:opaque`) and implements OAuth 2.1 / Security BCP refresh token rotation with family-level replay detection.
+
+---
+
+## 1. JWT vs. Opaque Access Tokens
+
+{% tabs %}
+{% tab title=":jwt Strategy (Default)" %}
+- **Self-Contained**: Access tokens and refresh tokens are signed JSON Web Tokens containing `sub`, `iss`, `cid`, `scope`, `jti`, `iat`, `exp`, and optional `aud` and `cnf` claims.
+- **Stateless Verification**: Resource servers verify signatures and claims locally without a database lookup, consulting `TokenStore#revoked?(jti)` only for revocation checks.
+- **Best For**: Distributed microservices and high-throughput APIs.
+{% endtab %}
+
+{% tab title=":opaque Strategy" %}
+- **Reference Tokens**: Access and refresh tokens are 256-bit cryptographically random hex strings (`Random::Secure.hex(32)`).
+- **Zero Client-Visible Metadata**: Token claims (`cid`, `sub`, `scope`, `exp`, `cnf`) are stored exclusively server-side in `Authly.config.token_store`.
+- **Best For**: Environments where token claims must remain confidential from clients or where immediate centralized revocation is required.
+{% endtab %}
+{% endtabs %}
+
+---
+
+## 2. Strict Separation of Access and Refresh Tokens
+
+Every token issued by Authly carries an explicit `"token_type"` claim (`"access_token"` or `"refresh_token"`):
+
+{% hint style="warning" %}
+`Authly::Handler::Bearer`, `Authly.valid?`, and `POST /oauth/introspect` **only accept `"access_token"`**. Presenting a refresh token as a Bearer token on an API route or to `/oauth/introspect` is always rejected, even though both tokens are signed with the same key.
+{% endhint %}
+
+---
+
+## 3. Refresh Token Rotation & Family Reuse Detection
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Authly as Authly (/oauth/token)
+ participant Store as TokenStore
+
+ Client->>Authly: Redeem Refresh Token R1 (fid: F1)
+ Authly->>Store: consume(R1) -> true
+ Authly-->>Client: Issue Access Token + New Refresh Token R2 (fid: F1)
+ Note over Client,Authly: Attacker or replayed request presents R1 again
+ Client->>Authly: Redeem Refresh Token R1 (fid: F1)
+ Authly->>Store: consume(R1) -> false (Already Consumed!)
+ Authly->>Store: revoke_family(F1) (Invalidates R2 and entire family)
+ Authly-->>Client: 400 invalid_grant
+```
+
+When a refresh token is redeemed at `POST /oauth/token` (`grant_type=refresh_token`):
+
+1. **Single-Use Consumption**: Authly calls `token_store.consume(jti)` to atomically mark the presented refresh token as used.
+2. **Rotation within a Family (`fid`)**: A new access token and a **new** refresh token are issued, carrying the same rotation family ID (`fid`).
+3. **Replay / Theft Detection**: If an already-consumed refresh token is presented a second time (indicating either a network replay or that an attacker stole the token and raced the legitimate client), Authly immediately calls `RefreshToken.revoke_family(fid)`. Every refresh token in that family is invalidated, forcing a fresh sign-in.
+4. **Absolute Family Lifetime (`fexp`)**: Continuous rotation cannot extend a session indefinitely. Every refresh token family carries an absolute expiration timestamp (`fexp`, controlled by `config.refresh_absolute_ttl = 30.days`). No rotated refresh token can outlive its family's `fexp`.
+5. **Scope Narrowing**: A refresh request may omit `scope` (retaining the original scope) or request a narrower subset of scopes. Even when an access token is narrowed, the replacement refresh token preserves the original granted scope so subsequent refreshes can request other subsets of the original grant.
diff --git a/docs/how-to/README.md b/docs/how-to/README.md
new file mode 100644
index 0000000..95f88fa
--- /dev/null
+++ b/docs/how-to/README.md
@@ -0,0 +1,28 @@
+---
+description: Practical recipes for configuring Authly in production environments.
+icon: wrench
+---
+
+# How-To Guides
+
+These guides show you how to solve specific problems in production—such as connecting Authly to PostgreSQL and Redis, enforcing FAPI security profiles, rotating signing keys, and protecting API routes.
+
+{% content-ref url="custom-stores-and-models.md" %}
+[custom-stores-and-models.md](custom-stores-and-models.md)
+{% endcontent-ref %}
+
+{% content-ref url="protect-resource-apis.md" %}
+[protect-resource-apis.md](protect-resource-apis.md)
+{% endcontent-ref %}
+
+{% content-ref url="fapi-and-advanced-security.md" %}
+[fapi-and-advanced-security.md](fapi-and-advanced-security.md)
+{% endcontent-ref %}
+
+{% content-ref url="key-rotation-and-jwks.md" %}
+[key-rotation-and-jwks.md](key-rotation-and-jwks.md)
+{% endcontent-ref %}
+
+{% content-ref url="dynamic-client-registration.md" %}
+[dynamic-client-registration.md](dynamic-client-registration.md)
+{% endcontent-ref %}
diff --git a/docs/how-to/custom-stores-and-models.md b/docs/how-to/custom-stores-and-models.md
new file mode 100644
index 0000000..b13b82d
--- /dev/null
+++ b/docs/how-to/custom-stores-and-models.md
@@ -0,0 +1,235 @@
+---
+description: Connect Authly to PostgreSQL and Redis by implementing AuthorizableClient, AuthorizableOwner, TokenStore, and ChallengeStore.
+icon: database
+---
+
+# Implement Custom Clients, Owners & Redis Stores
+
+Authly's built-in stores (`InMemoryStore`, `InMemoryStateStore`, `InMemoryChallengeStore`, `InMemoryPARStore`, `InMemoryDeviceCodeStore`, `InMemoryAttemptLimiter`) are designed for single-process development and testing. For production or multi-node deployments, implement Authly's store and model interfaces against your database and Redis.
+
+{% hint style="danger" %}
+**Atomicity Requirement for Shared Stores**
+Single-use credentials (`TokenStore#consume`, `StateStore#consume`, `PARStore#consume`, `ChallengeStore#consume!`, and `AttemptLimiter#increment`) **must** be implemented as single atomic operations in Redis or SQL—never as a `valid?`/`fetch` check followed by a delete.
+{% endhint %}
+
+---
+
+## 1. Implement `Authly::AuthorizableClient` & `Authly::AuthorizableOwner`
+
+{% tabs %}
+{% tab title="AuthorizableClient" %}
+```crystal title="src/auth/database_clients.cr" lineNumbers="true"
+class DatabaseClients
+ include Authly::AuthorizableClient
+
+ def valid_client?(client_id : String) : Bool
+ !ClientRepo.find(client_id).nil?
+ end
+
+ def valid_redirect?(client_id : String, redirect_uri : String) : Bool
+ client = ClientRepo.find(client_id)
+ return false unless client
+ client.redirect_uris.includes?(redirect_uri)
+ end
+
+ def authorized?(client_id : String, client_secret : String) : Bool
+ client = ClientRepo.find(client_id)
+ return false unless client && client.active?
+ Crypto::Subtle.constant_time_compare(client.secret, client_secret)
+ end
+
+ def allowed_scopes?(client_id : String, scopes : String) : Bool
+ client = ClientRepo.find(client_id)
+ return false unless client
+
+ requested = scopes.split(' ', remove_empty: true)
+ !requested.empty? && requested.all? { |s| client.scopes.includes?(s) }
+ end
+
+ def allowed_grant_type?(client_id : String, grant_type : String) : Bool
+ client = ClientRepo.find(client_id)
+ return false unless client
+ client.grant_types.includes?(grant_type)
+ end
+
+ def verify_challenge_response?(
+ challenge : String,
+ response : String,
+ client_id : String = "",
+ key_id : String? = nil,
+ algorithm : String? = nil,
+ ) : Bool
+ client = ClientRepo.find(client_id)
+ return false unless client
+
+ Authly::ChallengeVerifier.verify?(
+ challenge: challenge,
+ response: response,
+ public_key: client.public_key_pem,
+ secret: client.secret,
+ algorithm: algorithm,
+ )
+ end
+end
+```
+{% endtab %}
+
+{% tab title="AuthorizableOwner" %}
+```crystal title="src/auth/database_owners.cr" lineNumbers="true"
+class DatabaseOwners
+ include Authly::AuthorizableOwner
+
+ def authorized?(username : String, password : String) : Bool
+ user = UserRepo.find_by_username(username)
+ return false unless user && user.active?
+ Crypto::Bcrypt::Password.new(user.password_digest).verify(password)
+ end
+
+ def id_token(user_id : String) : Hash(String, String | Int64)
+ user = UserRepo.find_by_username_or_id!(user_id)
+ {
+ "user_id" => user.username,
+ "sub" => user.id,
+ "iat" => Time.utc.to_unix,
+ "exp" => Authly.config.access_ttl.from_now.to_unix,
+ "iss" => Authly.config.issuer,
+ }
+ end
+
+ def verify_challenge_response?(
+ challenge : String,
+ response : String,
+ identifier : String? = nil,
+ username : String? = nil,
+ owner_id : String? = nil,
+ key_id : String? = nil,
+ algorithm : String? = nil,
+ ) : Bool
+ target = identifier.presence || owner_id.presence || username.presence || key_id.presence
+ return false unless target
+
+ user = UserRepo.find_by_identifier(target)
+ return false unless user
+
+ Authly::ChallengeVerifier.verify?(
+ challenge: challenge,
+ response: response,
+ public_key: user.public_key_pem,
+ secret: user.shared_secret,
+ algorithm: algorithm,
+ )
+ end
+end
+```
+{% endtab %}
+{% endtabs %}
+
+---
+
+## 2. Implement Redis-Backed `TokenStore` & `ChallengeStore`
+
+{% tabs %}
+{% tab title="RedisTokenStore" %}
+```crystal title="src/auth/redis_token_store.cr" lineNumbers="true"
+class RedisTokenStore
+ include Authly::TokenStore
+
+ def initialize(@redis : Redis::PooledClient)
+ end
+
+ def store(token_id : String, payload)
+ ttl = (payload["exp"].to_s.to_i64 - Time.utc.to_unix).clamp(1_i64..)
+ @redis.set("authly:token:#{token_id}", payload.to_json, ex: ttl)
+ end
+
+ def fetch(token_id : String)
+ raw = @redis.get("authly:token:#{token_id}")
+ raise Authly::Error.invalid_token unless raw
+ JSON.parse(raw).as_h.transform_values do |v|
+ v.as_i64? || v.as_f? || v.as_bool? || v.as_s
+ end
+ end
+
+ def revoke(token_id : String, ttl : Time::Span = Authly.config.refresh_ttl)
+ @redis.set("authly:revoked:#{token_id}", "1", ex: ttl.total_seconds.to_i)
+ end
+
+ def revoke(token_id : String)
+ revoke(token_id, Authly.config.refresh_ttl)
+ end
+
+ def revoked?(token_id : String) : Bool
+ @redis.exists("authly:revoked:#{token_id}") == 1
+ end
+
+ def valid?(token_id : String) : Bool
+ !revoked?(token_id)
+ end
+
+ # Atomic single-use redemption via SET NX EX
+ def consume(token_id : String, ttl : Time::Span = Authly.config.refresh_ttl) : Bool
+ @redis.set("authly:revoked:#{token_id}", "1", nx: true, ex: ttl.total_seconds.to_i) == "OK"
+ end
+
+ def consume(token_id : String) : Bool
+ consume(token_id, Authly.config.refresh_ttl)
+ end
+end
+```
+{% endtab %}
+
+{% tab title="RedisChallengeStore" %}
+```crystal title="src/auth/redis_challenge_store.cr" lineNumbers="true"
+class RedisChallengeStore
+ include Authly::ChallengeStore
+
+ def initialize(@redis : Redis::PooledClient)
+ end
+
+ def store(challenge : Authly::Challenge) : Authly::Challenge
+ ttl = challenge.expires_in.clamp(1..)
+ @redis.set("authly:challenge:#{challenge.challenge_id}", challenge.to_json, ex: ttl)
+ challenge
+ end
+
+ def fetch(challenge_id : String) : Authly::Challenge?
+ raw = @redis.get("authly:challenge:#{challenge_id}")
+ return unless raw
+ data = Authly::Challenge.from_json(raw)
+ data unless data.expired? || data.consumed?
+ end
+
+ # Atomic GETDEL ensures single-use consumption across nodes
+ def consume!(challenge_id : String) : Authly::Challenge?
+ raw = @redis.command(["GETDEL", "authly:challenge:#{challenge_id}"]).as?(String)
+ return unless raw
+ data = Authly::Challenge.from_json(raw)
+ return if data.expired? || data.consumed?
+ data.consumed = true
+ data
+ end
+
+ def delete(challenge_id : String) : Bool
+ @redis.del("authly:challenge:#{challenge_id}") > 0
+ end
+
+ def cleanup_expired : Nil
+ # Redis expires keys automatically via EX
+ end
+end
+```
+{% endtab %}
+{% endtabs %}
+
+---
+
+## 3. Register Custom Stores in `Authly.configure`
+
+```crystal title="src/config/authly.cr"
+Authly.configure do |config|
+ config.clients = DatabaseClients.new
+ config.owners = DatabaseOwners.new
+ config.token_store = RedisTokenStore.new(redis)
+ config.challenge_store = RedisChallengeStore.new(redis)
+end
+```
diff --git a/docs/how-to/dynamic-client-registration.md b/docs/how-to/dynamic-client-registration.md
new file mode 100644
index 0000000..f4c658b
--- /dev/null
+++ b/docs/how-to/dynamic-client-registration.md
@@ -0,0 +1,112 @@
+---
+description: Enable RFC 7591 Dynamic Client Registration and RFC 8628 Device Authorization with rate-limiting protection.
+icon: tv
+---
+
+# Configure Dynamic Client Registration & Device Flow
+
+This guide explains how to enable **OAuth 2.0 Dynamic Client Registration (RFC 7591)** and **Device Authorization Grant (RFC 8628)** with rate-limiting protection.
+
+---
+
+## 1. Enable Dynamic Client Registration (`POST /oauth/register`)
+
+{% hint style="warning" %}
+**Registration Is Never Anonymous**
+Even when `allow_dynamic_registration = true`, you must configure `registration_access_token`, `authenticate_registration`, or both. Without at least one, `POST /oauth/register` responds `500` and registers nothing.
+{% endhint %}
+
+```crystal title="src/config/authly.cr" lineNumbers="true"
+Authly.configure do |config|
+ config.allow_dynamic_registration = true
+ config.client_store = Authly::InMemoryClientStore.new
+
+ # Require an Initial Access Token (Bearer) OR a custom authentication proc
+ config.registration_access_token = ENV["AUTHLY_REGISTRATION_TOKEN"]
+ config.authenticate_registration = ->(ctx : HTTP::Server::Context) {
+ ctx.request.headers["X-Internal-Admin"]? == "true"
+ }
+
+ # Allowlists for client metadata
+ config.registration_grant_types = ["authorization_code", "refresh_token", "challenge_response"]
+ config.registration_response_types = ["code"]
+ config.registration_scopes = ["openid", "profile", "read", "write"]
+
+ # Abuse protection
+ config.registration_max_body = 16 * 1024
+ config.registration_rate_limit = 10
+ config.registration_rate_window = 1.hour
+end
+```
+
+
+Example POST /oauth/register Request & Response
+
+```http
+POST /oauth/register HTTP/1.1
+Authorization: Bearer
+Content-Type: application/json
+
+{
+ "client_name": "Mobile App",
+ "redirect_uris": ["com.example.mobile:/callback"],
+ "grant_types": ["authorization_code", "refresh_token"],
+ "response_types": ["code"],
+ "scope": "openid profile read",
+ "token_endpoint_auth_method": "none"
+}
+```
+
+Response (`201 Created`):
+
+```json
+{
+ "client_id": "client_abcdef123456",
+ "client_id_issued_at": 1700000000,
+ "client_secret_expires_at": 0,
+ "redirect_uris": ["com.example.mobile:/callback"],
+ "client_name": "Mobile App",
+ "grant_types": ["authorization_code", "refresh_token"],
+ "response_types": ["code"],
+ "token_endpoint_auth_method": "none"
+}
+```
+
+
+---
+
+## 2. Enable Device Authorization Flow (`/oauth/device/code` & `/device`)
+
+```crystal title="src/config/authly.cr" lineNumbers="true"
+Authly.configure do |config|
+ # Required so the user can approve the user_code at GET/POST /device
+ config.authenticate_user = ->(ctx : HTTP::Server::Context) { MySession.user_id(ctx) }
+ config.login_url = ->(ctx : HTTP::Server::Context) { "/login?return_to=#{URI.encode_www_form(ctx.request.resource)}" }
+
+ # Rate limit user_code lookups at /device
+ config.device_attempt_limit = 10
+ config.device_attempt_window = 15.minutes
+
+ # Behind a trusted reverse proxy that overwrites X-Real-IP:
+ config.attempt_source = ->(ctx : HTTP::Server::Context) {
+ ctx.request.headers["X-Real-IP"]?
+ }
+end
+```
+
+{% tabs %}
+{% tab title="1. Request Device Code" %}
+```bash
+curl -X POST http://localhost:8080/oauth/device/code \
+ -d "client_id=1&scope=read"
+```
+{% endtab %}
+
+{% tab title="2. Poll Token Endpoint" %}
+```bash
+curl -X POST http://localhost:8080/oauth/token \
+ -u "1:secret" \
+ -d "grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code="
+```
+{% endtab %}
+{% endtabs %}
diff --git a/docs/how-to/fapi-and-advanced-security.md b/docs/how-to/fapi-and-advanced-security.md
new file mode 100644
index 0000000..d24caa7
--- /dev/null
+++ b/docs/how-to/fapi-and-advanced-security.md
@@ -0,0 +1,116 @@
+---
+description: Harden OAuth 2.0 flows with PKCE S256 enforcement, Pushed Authorization Requests (PAR), JARM, and mTLS Certificate-Bound Tokens.
+icon: lock
+---
+
+# Enable FAPI & Advanced Security (PKCE, PAR, JARM, mTLS)
+
+This guide shows how to configure Authly's **Financial-grade API (FAPI)** and **OAuth 2.0 Security Best Current Practice** features.
+
+---
+
+## 1. Enforce PKCE with `S256`
+
+Require Proof Key for Code Exchange (RFC 7636) on all `authorization_code` grants and reject the `plain` challenge method:
+
+```crystal title="src/config/authly.cr"
+Authly.configure do |config|
+ config.enforce_pkce = true
+ config.enforce_pkce_s256 = true
+end
+```
+
+---
+
+## 2. Require Pushed Authorization Requests (PAR / RFC 9126)
+
+Move authorization request parameters from front-channel browser URLs to an authenticated back-channel `POST /oauth/par` call:
+
+```crystal title="src/config/authly.cr"
+Authly.configure do |config|
+ config.par_ttl = 90.seconds
+ config.require_par = true
+end
+```
+
+{% tabs %}
+{% tab title="Step 1: Back-Channel Push (POST /oauth/par)" %}
+```http
+POST /oauth/par HTTP/1.1
+Authorization: Basic base64(client_id:client_secret)
+Content-Type: application/x-www-form-urlencoded
+
+response_type=code&redirect_uri=https://app.example.com/cb&scope=openid+read&state=xyz&code_challenge=CHALLENGE&code_challenge_method=S256
+```
+
+Response (`201 Created`):
+
+```json
+{
+ "request_uri": "urn:ietf:params:oauth:request_uri:4a8b9c...",
+ "expires_in": 90
+}
+```
+{% endtab %}
+
+{% tab title="Step 2: Front-Channel Redirect (GET /oauth/authorize)" %}
+```http
+GET /oauth/authorize?client_id=CLIENT_ID&request_uri=urn:ietf:params:oauth:request_uri:4a8b9c...
+```
+{% endtab %}
+{% endtabs %}
+
+---
+
+## 3. Enable JWT Secured Authorization Response Mode (JARM / RFC 9101)
+
+Sign and optionally encrypt authorization responses (`query.jwt`, `fragment.jwt`, `form_post.jwt`, `jwt`) so authorization codes and states cannot be tampered with or leaked in redirect parameters:
+
+```crystal title="src/config/authly.cr"
+Authly.configure do |config|
+ config.jarm_signing_key = File.read("jarm_private.pem")
+ config.jarm_signing_alg = JWT::Algorithm::RS256
+
+ # Optional JWE encryption
+ config.jarm_encryption_key = File.read("client_encryption_public.pem")
+ config.jarm_encryption_alg = "RSA-OAEP-256"
+ config.jarm_encryption_enc = "A256GCM"
+
+ config.default_jarm_response_mode = "query.jwt"
+ config.jarm_lifetime = 10.minutes
+end
+```
+
+---
+
+## 4. Bind Access Tokens to Client TLS Certificates (mTLS / RFC 8705)
+
+Bind access tokens to the SHA-256 thumbprint (`cnf.x5t#S256`) of the client's mutual TLS certificate:
+
+{% tabs %}
+{% tab title="Authly Configuration" %}
+```crystal title="src/config/authly.cr"
+Authly.configure do |config|
+ config.require_certificate_bound_tokens = true
+ config.trusted_ca_certificates = [
+ File.read("/etc/ssl/certs/client-ca.pem"),
+ ]
+end
+```
+{% endtab %}
+
+{% tab title="Nginx Reverse Proxy" %}
+```nginx title="/etc/nginx/conf.d/authly.conf"
+server {
+ listen 443 ssl;
+ ssl_client_certificate /etc/ssl/certs/client-ca.pem;
+ ssl_verify_client on;
+
+ location / {
+ proxy_pass http://authly_upstream;
+ proxy_set_header X-Client-Certificate $ssl_client_raw_cert;
+ }
+}
+```
+{% endtab %}
+{% endtabs %}
diff --git a/docs/how-to/key-rotation-and-jwks.md b/docs/how-to/key-rotation-and-jwks.md
new file mode 100644
index 0000000..db21239
--- /dev/null
+++ b/docs/how-to/key-rotation-and-jwks.md
@@ -0,0 +1,67 @@
+---
+description: Sign tokens with RS256/ES256, rotate signing keys with zero downtime, and publish /.well-known/jwks.json.
+icon: rotate
+---
+
+# Configure Asymmetric Keys, Key Rotation & JWKS
+
+By default, Authly signs JWTs with `HS256` using a symmetric `secret_key`. When Resource Servers are separate services or third parties verify ID tokens, configure **asymmetric signing** (`RS256`, `ES256`, etc.) and publish public keys at `/.well-known/jwks.json`.
+
+---
+
+## 1. Configure a Single Asymmetric Key Pair (`RS256` or `ES256`)
+
+```crystal title="src/config/authly.cr"
+Authly.configure do |config|
+ config.algorithm = JWT::Algorithm::RS256
+ config.secret_key = File.read("keys/rsa_private.pem")
+ config.public_key = File.read("keys/rsa_public.pem")
+end
+```
+
+{% hint style="info" %}
+**Private Keys Are Never Published**
+`Authly::JWKS` strictly parses DER SubjectPublicKeyInfo structures and verifies that the public key matches `config.algorithm`. If `public_key` accidentally contains a private key or mismatched key type, `/.well-known/jwks.json` refuses to publish it (`500`).
+{% endhint %}
+
+---
+
+## 2. Zero-Downtime Signing Key Rotation
+
+To rotate asymmetric keys without invalidating unexpired tokens signed by the retiring key:
+
+{% stepper %}
+{% step %}
+### Add New & Retiring Public Keys to `verification_keys`
+
+Map each key ID (`kid`) to its public PEM in `config.verification_keys`.
+{% endstep %}
+
+{% step %}
+### Switch `signing_key_id` & `secret_key` to the Active Key
+
+Set `config.signing_key_id` to the active `kid` and `config.secret_key` to the active private key PEM:
+
+```crystal title="src/config/authly.cr" lineNumbers="true"
+Authly.configure do |config|
+ config.algorithm = JWT::Algorithm::RS256
+
+ # Active signing key
+ config.signing_key_id = "2026-11-key"
+ config.secret_key = File.read("keys/2026-11-private.pem")
+
+ # Active + retiring verification public keys
+ config.verification_keys = {
+ "2026-11-key" => File.read("keys/2026-11-public.pem"),
+ "2026-10-key" => File.read("keys/2026-10-public.pem"),
+ }
+end
+```
+{% endstep %}
+
+{% step %}
+### Remove the Retiring Key After Token Expiry
+
+Once `max(access_ttl, refresh_ttl)` has elapsed since the rotation, remove `"2026-10-key"` from `config.verification_keys`.
+{% endstep %}
+{% endstepper %}
diff --git a/docs/how-to/protect-resource-apis.md b/docs/how-to/protect-resource-apis.md
new file mode 100644
index 0000000..84636b3
--- /dev/null
+++ b/docs/how-to/protect-resource-apis.md
@@ -0,0 +1,97 @@
+---
+description: Protect HTTP::Server and Azu endpoints with Authly::Handler::Bearer, RequireScope, and audience verification.
+icon: shield-check
+---
+
+# Protect Resource APIs & Mount in Azu
+
+Authly provides two standalone `HTTP::Handler` middlewares for protecting Resource Server routes:
+- `Authly::Handler::Bearer` — Validates `Authorization: Bearer ` and populates `context.authly_claims`.
+- `Authly::Handler::RequireScope` — Enforces required OAuth2 scopes on verified claims.
+
+---
+
+## 1. Mount `Bearer` & `RequireScope`
+
+{% tabs %}
+{% tab title="Standard HTTP::Server" %}
+```crystal title="src/server.cr" lineNumbers="true"
+require "http/server"
+require "authly"
+
+verifier = Authly::StrategyTokenVerifier.new(audience: "https://api.example.com")
+
+server = HTTP::Server.new([
+ Authly::Handler.new,
+ Authly::Handler::Bearer.new(
+ verifier,
+ realm: "api",
+ except_exact: ["/health", "/ready"],
+ except: ["/oauth", "/.well-known"],
+ ),
+ Authly::Handler::RequireScope.all("billing:read", "billing:export", prefixes: ["/billing"]),
+ Authly::Handler::RequireScope.any("admin", "super_admin", prefixes: ["/admin"]),
+ my_app_handler,
+])
+```
+{% endtab %}
+
+{% tab title="Azu Framework" %}
+```crystal title="src/app.cr" lineNumbers="true"
+require "azu"
+require "authly"
+
+app = Azu::App.default
+app.router.get "/users", UsersEndpoint.new
+
+server = app.server([
+ *app.default_handlers,
+ Authly::Handler.new,
+ Authly::Handler::Bearer.new(
+ except_exact: ["/health"],
+ except: ["/oauth", "/.well-known"],
+ ),
+ Authly::Handler::RequireScope.all("read:users", prefixes: ["/users"]),
+] of HTTP::Handler)
+```
+{% endtab %}
+{% endtabs %}
+
+{% hint style="warning" %}
+**Prefer `except` and `except_exact` over `prefixes` on `Bearer`**
+When `Bearer` is configured without `prefixes`, it protects **every** route except those in `except` and `except_exact`, ensuring any new top-level route added later is protected by default.
+{% endhint %}
+
+---
+
+## 2. Read Verified Claims in Endpoints
+
+Inside your application handler, access the verified claims via `context.authly_claims!`:
+
+```crystal title="src/endpoints/users_endpoint.cr"
+claims = context.authly_claims!
+claims.subject # String — The `sub` claim (resource owner or client ID)
+claims.client_id # String — The `cid` claim (client ID)
+claims.scopes # Array(String) — Granted scopes
+claims.scope?("write") # Bool — Check if a specific scope is granted
+claims["jti"].as_s # JSON::Any — Access any additional claim
+```
+
+---
+
+## 3. Customize Token Audience & Claims (`IssuanceOptions`)
+
+Use `config.issuance_options` to attach per-client audiences, custom lifetimes, or custom scalar claims at issuance time:
+
+```crystal title="src/config/authly.cr"
+Authly.configure do |config|
+ config.issuance_options = ->(client_id : String, grant_type : String) {
+ Authly::IssuanceOptions.new(
+ audience: "https://orders.example.com",
+ expires_at: 15.minutes.from_now,
+ additional_claims: {"tenant_id" => "acme"} of String => Authly::AdditionalClaim,
+ issue_refresh: grant_type != "client_credentials",
+ )
+ }
+end
+```
diff --git a/docs/reference/README.md b/docs/reference/README.md
new file mode 100644
index 0000000..58bb4d3
--- /dev/null
+++ b/docs/reference/README.md
@@ -0,0 +1,24 @@
+---
+description: Exhaustive technical specifications for Authly's configuration, HTTP endpoints, Crystal APIs, and error codes.
+icon: book-bookmark
+---
+
+# Reference
+
+Use these pages to look up exact configuration properties, wire protocol parameters, Crystal type signatures, and HTTP error responses.
+
+{% content-ref url="configuration.md" %}
+[configuration.md](configuration.md)
+{% endcontent-ref %}
+
+{% content-ref url="http-endpoints.md" %}
+[http-endpoints.md](http-endpoints.md)
+{% endcontent-ref %}
+
+{% content-ref url="grants-and-interfaces.md" %}
+[grants-and-interfaces.md](grants-and-interfaces.md)
+{% endcontent-ref %}
+
+{% content-ref url="errors.md" %}
+[errors.md](errors.md)
+{% endcontent-ref %}
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
new file mode 100644
index 0000000..e577ccf
--- /dev/null
+++ b/docs/reference/configuration.md
@@ -0,0 +1,105 @@
+---
+description: Complete reference of every setting, store, callback, and TTL on Authly::Configuration.
+icon: sliders
+---
+
+# `Authly::Configuration`
+
+`Authly.configure { |config| ... }` yields the global `Authly::Configuration` singleton (`Authly.config`).
+
+{% hint style="info" %}
+All settings can be inspected at runtime via `Authly.config.` and reset to defaults in tests via `Authly.config.reset!`.
+{% endhint %}
+
+---
+
+## Core Issuer & Cryptographic Keys
+
+| Property | Type | Default | Description |
+| :--- | :--- | :--- | :--- |
+| `issuer` | `String` | `"The Authority Server Provider"` | The `iss` claim emitted in JWT access tokens, refresh tokens, ID tokens, and discovery metadata. |
+| `base_url` | `String` | `"http://localhost:8080"` | Base URL used to construct endpoint URLs in discovery metadata and fallback `token_audience`. |
+| `access_token_audience` | `String?` | `nil` | Explicit `aud` claim emitted on access tokens (`config.token_audience` returns `access_token_audience.presence \|\| base_url.presence`). |
+| `secret_key` | `String` | `Random::Secure.hex(16)` | Secret key (for HMAC) or private key PEM (for RSA/ECDSA) used to sign JWTs and anti-CSRF form tokens. |
+| `public_key` | `String` | Follows `secret_key` | Verification key (or public key PEM for asymmetric algorithms). |
+| `signing_key_id` | `String?` | `nil` | Optional `kid` header attached when signing JWTs. |
+| `verification_keys` | `Hash(String, String)` | `{}` | Map of `kid => public_key_pem` for key rotation and JWKS publication. |
+| `algorithm` | `JWT::Algorithm` | `JWT::Algorithm::HS256` | JWT signing and verification algorithm. |
+| `token_strategy` | `Symbol` | `:jwt` | Token representation strategy: `:jwt` or `:opaque`. |
+| `issue_refresh_tokens` (`issue_refresh_tokens?`) | `Bool` | `true` | Whether `AccessToken` issues a `refresh_token` by default. |
+| `grant_types` | `Array(String)` | `%w[authorization_code client_credentials password refresh_token implicit urn:ietf:params:oauth:grant-type:device_code challenge_response urn:ietf:params:oauth:grant-type:challenge]` | Enabled OAuth2 grant types. |
+
+---
+
+## Lifetimes (TTLs)
+
+| Property | Type | Default | Description |
+| :--- | :--- | :--- | :--- |
+| `access_ttl` | `Time::Span` | `1.hour` | Access token lifetime (`expires_in`). |
+| `refresh_ttl` | `Time::Span` | `1.day` | Individual refresh token lifetime. |
+| `refresh_absolute_ttl` | `Time::Span` | `30.days` | Maximum lifetime of an entire refresh token rotation family (`fexp`). |
+| `code_ttl` | `Time::Span` | `5.minutes` | Authorization code lifetime. |
+| `consent_ttl` | `Time::Span` | `10.minutes` | Consent and device verification anti-CSRF form token lifetime. |
+| `par_ttl` | `Time::Span` | `90.seconds` | Pushed Authorization Request (`request_uri`) lifetime. |
+| `challenge_ttl` | `Time::Span` | `60.seconds` | Challenge-Response nonce (`challenge_id`) lifetime. |
+| `jarm_lifetime` | `Time::Span` | `10.minutes` | JARM response JWT lifetime. |
+
+---
+
+## Stores & Providers
+
+| Property | Type | Default | Description |
+| :--- | :--- | :--- | :--- |
+| `owners` | `Authly::AuthorizableOwner` | `Authly::Owners.new` | Resource owner authenticator and claim provider. |
+| `clients` | `Authly::AuthorizableClient` | `Authly::Clients.new` | OAuth2 client authenticator and policy provider. |
+| `token_store` | `Authly::TokenStore` | `Authly::InMemoryStore.new` | Token metadata and revocation/consumption store. |
+| `state_store` | `Authly::StateStore` | `Authly::InMemoryStateStore.new` | Single-use nonce store for consent and device forms. |
+| `device_code_store` | `Authly::DeviceCodeStore` | `Authly::InMemoryDeviceCodeStore.new` | Device authorization state store. |
+| `par_store` | `Authly::PARStore` | `Authly::InMemoryPARStore.new` | Pushed Authorization Request store. |
+| `challenge_store` | `Authly::ChallengeStore` | `Authly::InMemoryChallengeStore.new` | Challenge-Response store. |
+| `client_store` | `Authly::ClientStore` | `Authly::InMemoryClientStore.new` | Dynamically registered client store. |
+| `attempt_limiter` | `Authly::AttemptLimiter` | `Authly::InMemoryAttemptLimiter.new` | Rate limiter for device verification lookups. |
+| `registration_limiter` | `Authly::AttemptLimiter` | `Authly::InMemoryAttemptLimiter.new` | Rate limiter for failed dynamic client registration attempts. |
+
+---
+
+## Host Callbacks
+
+| Property | Signature | Default | Description |
+| :--- | :--- | :--- | :--- |
+| `authenticate_user` | `Proc(HTTP::Server::Context, String?)?` | `nil` | Resolves the currently signed-in resource owner's subject ID from the host session. Required for `/oauth/authorize` and `/device`. |
+| `login_url` | `Proc(HTTP::Server::Context, String)?` | `nil` | Redirect URL when `authenticate_user` returns `nil` (otherwise responds `401 login_required`). |
+| `render_consent` | `Proc(HTTP::Server::Context, AuthorizationRequest, String, String, Nil)?` | `nil` | Optional custom renderer for the `/oauth/authorize` consent page. |
+| `userinfo` | `Proc(String, Hash(String, JSON::Any))?` | `nil` | Resolves additional OIDC UserInfo claims for a verified `sub`. |
+| `issuance_options` | `Proc(String, String, IssuanceOptions)?` | `nil` | Returns `IssuanceOptions` given `(client_id, grant_type)` at `/oauth/token`. |
+| `attempt_source` | `Proc(HTTP::Server::Context, String?)` | Remote socket IP | Identifies the caller source for rate limiting (`attempt_limiter` and `registration_limiter`). |
+| `authenticate_registration` | `Proc(HTTP::Server::Context, Bool)?` | `nil` | Custom authenticator for `POST /oauth/register`. |
+
+---
+
+## Security & Protocol Flags
+
+| Property | Type | Default | Description |
+| :--- | :--- | :--- | :--- |
+| `enforce_pkce` (`enforce_pkce?`) | `Bool` | `false` | Require PKCE on all authorization code exchanges. |
+| `enforce_pkce_s256` (`enforce_pkce_s256?`) | `Bool` | `false` | Require `S256` PKCE method (disallow `plain`). |
+| `require_par` (`require_par?`) | `Bool` | `false` | Require Pushed Authorization Requests (`request_uri`) at `/oauth/authorize`. |
+| `challenge_max_attempts` | `Int32` | `1` | Maximum verification attempts before a challenge is deleted (`1` = immediate single-attempt invalidation). |
+| `device_attempt_limit` | `Int32` | `10` | Max user code lookups at `/device` per owner/source per window. |
+| `device_attempt_window` | `Time::Span` | `15.minutes` | Rate-limit window for `/device` lookups. |
+| `require_certificate_bound_tokens` (`require_certificate_bound_tokens?`) | `Bool` | `false` | Bind access tokens to client mTLS certificate thumbprints (`cnf.x5t#S256`). |
+| `trusted_ca_certificates` | `Array(String)` | `[] of String` | Trusted CA PEMs for mTLS client certificate verification. |
+| `allow_dynamic_registration` (`allow_dynamic_registration?`) | `Bool` | `false` | Enable `POST /oauth/register`. |
+| `registration_access_token` | `String?` | `nil` | Bearer token required at `POST /oauth/register` (unless `authenticate_registration` is set). |
+| `registration_grant_types` | `Array(String)` | `["authorization_code", "refresh_token"]` | Allowed `grant_types` in dynamic client registration. |
+| `registration_response_types` | `Array(String)` | `["code"]` | Allowed `response_types` in dynamic client registration. |
+| `registration_scopes` | `Array(String)` | `[] of String` | Allowed `scope` values in dynamic client registration. |
+| `registration_max_body` | `Int32` | `16384` | Maximum request body size (bytes) for `POST /oauth/register`. |
+| `registration_rate_limit` | `Int32` | `10` | Max failed registration auth attempts per source per window. |
+| `registration_rate_window` | `Time::Span` | `1.hour` | Rate-limit window for failed registration attempts. |
+| `jarm_signing_key` | `String?` | `nil` | Signing key for JARM responses. |
+| `jarm_signing_alg` | `JWT::Algorithm` | `JWT::Algorithm::RS256` | Signing algorithm for JARM responses. |
+| `jarm_encryption_key` | `String?` | `nil` | Encryption key for JARM JWE responses. |
+| `jarm_encryption_alg` | `String?` | `nil` | Key encryption algorithm for JARM JWE responses (e.g., `"RSA-OAEP-256"`). |
+| `jarm_encryption_enc` | `String?` | `nil` | Content encryption encoding for JARM JWE responses (e.g., `"A256GCM"`). |
+| `default_jarm_response_mode` | `String?` | `nil` | Default JARM `response_mode` (`"query.jwt"`, `"fragment.jwt"`, `"form_post.jwt"`, `"jwt"`). |
diff --git a/docs/reference/errors.md b/docs/reference/errors.md
new file mode 100644
index 0000000..4ac20eb
--- /dev/null
+++ b/docs/reference/errors.md
@@ -0,0 +1,46 @@
+---
+description: Complete catalog of Authly::Error codes, HTTP status mappings, and RFC 6750 WWW-Authenticate headers.
+icon: triangle-exclamation
+---
+
+# Errors & RFC 6750 Challenge Responses
+
+## 1. `Authly::Error(Code)` (`src/authly/error.cr`)
+
+All OAuth2 protocol errors inherit from `Authly::Error(Code) < Exception`, carrying `#code : Int32` (HTTP status code), `#type : Symbol` (OAuth2 error code), and `#message : String` (`error_description`).
+
+| Factory Method | Error Symbol (`#type`) | HTTP Status (`#code`) | Default `error_description` (`#message`) |
+| :--- | :--- | :--- | :--- |
+| `Error.invalid_request` | `:invalid_request` | `400` | `"The request is missing a parameter so the server can't proceed with the request"` |
+| `Error.invalid_client` | `:invalid_client` | `401` | `"Client authentication failed, such as if the request contains an invalid client ID or secret."` |
+| `Error.unauthorized_client` | `:unauthorized_client` | `401` | `"This client is not authorized to use the requested grant type"` |
+| `Error.invalid_grant` | `:invalid_grant` | `400` | `"The authorization code is invalid or expired."` |
+| `Error.unsupported_grant_type` | `:unsupported_grant_type` | `400` | `"Invalid or unknown grant type"` |
+| `Error.invalid_scope` | `:invalid_scope` | `400` | `"Invalid scope value in the request"` |
+| `Error.invalid_redirect_uri` | `:invalid_redirect_uri` | `400` | `"Invalid redirect uri"` |
+| `Error.invalid_state` | `:invalid_state` | `400` | `"Invalid state"` |
+| `Error.invalid_response_type` | `:invalid_response_type` | `400` | `"The response type is invalid."` |
+| `Error.owner_credentials` | `:owner_credentials` | `400` | `"Invalid owner credentials"` |
+| `Error.access_denied` | `:access_denied` | `400` | `"The user or authorization server denied the request"` |
+| `Error.unsupported_token_type` | `:unsupported_token_type` | `400` | `"The authorization server does not support the presented token type"` |
+| `Error.invalid_token` | `:invalid_token` | `400` | `"The token is invalid or expired"` |
+| `Error.authorization_pending` | `:authorization_pending` | `400` | `"The authorization request is still pending; the client should retry the request"` |
+| `Error.slow_down` | `:slow_down` | `400` | `"The client is polling too frequently and should slow down"` |
+| `Error.expired_token` | `:expired_token` | `400` | `"The device code has expired; the client should request a new device authorization"` |
+| `Error.expired_challenge` | `:expired_challenge` | `400` | `"The challenge has expired; the client should request a new challenge"` |
+| `Error.invalid_request_uri` | `:invalid_request_uri` | `400` | `"The request_uri is invalid, expired, or does not exist"` |
+| `Error.invalid_request_object` | `:invalid_request_object` | `400` | `"The request object is invalid or malformed"` |
+
+---
+
+## 2. Resource Server Bearer Error Responses (RFC 6750)
+
+`Authly::Handler::Bearer` and `Authly::Handler::RequireScope` return RFC 6750 `WWW-Authenticate` challenges with `Cache-Control: no-store`:
+
+| Condition | HTTP Status | `WWW-Authenticate` Header | JSON Body |
+| :--- | :--- | :--- | :--- |
+| Missing `Authorization` header or non-Bearer scheme | `401` | `Bearer realm=""` | `{}` |
+| Malformed header, multiple token sources, or empty token | `400` | `Bearer realm="", error="invalid_request"` | `{"error":"invalid_request","error_description":"The request is malformed"}` |
+| Invalid signature, expired, revoked, wrong `aud`/`iss`, or refresh token | `401` | `Bearer realm="", error="invalid_token"` | `{"error":"invalid_token","error_description":"The access token is invalid, expired or revoked"}` |
+| Token lacks required scope | `403` | `Bearer realm="", error="insufficient_scope", scope=""` | `{"error":"insufficient_scope","error_description":"The access token does not grant the required scope"}` |
+| `TokenStore` raises `Authly::StoreUnavailable` or `IO::Error` | `503` | *(none; `Retry-After: 1`)* | `{"error":"temporarily_unavailable","error_description":"The token store is unavailable"}` |
diff --git a/docs/reference/grants-and-interfaces.md b/docs/reference/grants-and-interfaces.md
new file mode 100644
index 0000000..680305d
--- /dev/null
+++ b/docs/reference/grants-and-interfaces.md
@@ -0,0 +1,245 @@
+---
+description: Signatures and contracts for Authly's Crystal modules, structs, classes, and store interfaces.
+icon: code
+---
+
+# Crystal API, Grants & Store Interfaces
+
+This document lists the primary Crystal modules, structs, classes, and interfaces in `Authly`.
+
+---
+
+## Top-Level `Authly` Module (`src/authly.cr`)
+
+```crystal title="src/authly.cr"
+module Authly
+ def self.configure(& : Configuration -> _)
+ def self.config : Configuration
+ def self.clients : AuthorizableClient
+ def self.owners : AuthorizableOwner
+
+ # Generate an authorization code or implicit token for `response_type` ("code" or "token")
+ def self.code(response_type, *args)
+
+ # Execute a grant strategy and issue an AccessToken
+ def self.access_token(grant_type : String, **args) : AccessToken
+
+ # Encode and decode JWTs using Authly.config
+ def self.jwt_encode(payload, configuration : Configuration = config) : String
+ def self.jwt_decode(token : String) : Tuple
+ def self.jwt_decode(token : String, secret_key : String) : Tuple
+ def self.decode_with(token : String, configuration : Configuration) : Tuple
+
+ # Token lifecycle management via TokenManager
+ def self.revoke(token : String)
+ def self.revoked?(token : String) : Bool
+ def self.valid?(token : String) : Bool
+ def self.introspect(token : String)
+end
+```
+
+---
+
+## Client & Owner Interfaces
+
+{% tabs %}
+{% tab title="Authly::AuthorizableClient" %}
+```crystal title="src/authly/authorizable_client.cr"
+module Authly::AuthorizableClient
+ abstract def valid_redirect?(client_id : String, redirect_uri : String) : Bool
+ abstract def authorized?(client_id : String, client_secret : String)
+ abstract def allowed_scopes?(client_id : String, scopes : String) : Bool
+
+ def valid_client?(client_id : String) : Bool
+ def client_public_key(client_id : String) : String?
+ def jarm_required?(client_id : String) : Bool
+ def jarm_signing_algorithm(client_id : String) : String?
+ def jarm_encryption_algorithm(client_id : String) : String?
+ def jarm_encryption_encoding(client_id : String) : String?
+
+ def verify_challenge_response?(
+ challenge : String,
+ response : String,
+ client_id : String = "",
+ key_id : String? = nil,
+ algorithm : String? = nil,
+ ) : Bool
+end
+```
+{% endtab %}
+
+{% tab title="Authly::AuthorizableOwner" %}
+```crystal title="src/authly/authorizable_owner.cr"
+module Authly::AuthorizableOwner
+ abstract def authorized?(username : String, password : String) : Bool
+ abstract def id_token(user_id : String) : Hash(String, String | Int64)
+
+ def verify_challenge_response?(
+ challenge : String,
+ response : String,
+ identifier : String? = nil,
+ username : String? = nil,
+ owner_id : String? = nil,
+ key_id : String? = nil,
+ algorithm : String? = nil,
+ ) : Bool
+end
+```
+{% endtab %}
+{% endtabs %}
+
+---
+
+## Store Interfaces
+
+{% tabs %}
+{% tab title="TokenStore" %}
+```crystal title="src/authly/token_store.cr"
+module Authly::TokenStore
+ abstract def store(token_id : String, payload)
+ abstract def fetch(token_id : String)
+ abstract def revoke(token_id : String)
+ abstract def revoked?(token_id : String) : Bool
+ abstract def valid?(token_id : String) : Bool
+
+ def revoke(token_id : String, ttl : Time::Span)
+ def consume(token_id : String) : Bool
+ def consume(token_id : String, ttl : Time::Span) : Bool
+end
+```
+{% endtab %}
+
+{% tab title="ChallengeStore & Challenge" %}
+```crystal title="src/authly/challenge_store.cr"
+struct Authly::Challenge
+ include JSON::Serializable
+
+ property challenge_id : String
+ property challenge : String
+ property client_id : String
+ property username : String?
+ property owner_id : String?
+ property key_id : String?
+ property expires_at : Time
+ property? consumed : Bool
+ property attempts : Int32
+
+ def self.generate(
+ client_id : String,
+ ttl : Time::Span = Authly.config.challenge_ttl,
+ username : String? = nil,
+ owner_id : String? = nil,
+ key_id : String? = nil,
+ ) : Challenge
+
+ def expired? : Bool
+ def expires_in : Int32
+ def identifier : String?
+end
+
+module Authly::ChallengeStore
+ abstract def store(challenge : Challenge) : Challenge
+ abstract def fetch(challenge_id : String) : Challenge?
+ abstract def consume!(challenge_id : String) : Challenge?
+ abstract def delete(challenge_id : String) : Bool
+ abstract def cleanup_expired : Nil
+
+ def valid?(challenge_id : String) : Bool
+ def record_attempt!(challenge_id : String, max_attempts : Int32 = 1) : Challenge
+end
+```
+{% endtab %}
+
+{% tab title="StateStore, PARStore, DeviceCodeStore & AttemptLimiter" %}
+```crystal
+module Authly::StateStore
+ abstract def store(state : String, ttl : Time::Span = 5.minutes)
+ abstract def valid?(state : String) : Bool
+ abstract def consume(state : String) : Bool
+end
+
+module Authly::PARStore
+ abstract def store(request_uri : String, params : Hash(String, String), client_id : String, ttl : Time::Span = 90.seconds)
+ abstract def fetch(request_uri : String) : PARData?
+ abstract def consume(request_uri : String) : Bool
+ abstract def valid?(request_uri : String) : Bool
+end
+
+module Authly::DeviceCodeStore
+ abstract def store(device_code : String, data : DeviceCodeData)
+ abstract def fetch(device_code : String) : DeviceCodeData?
+ abstract def fetch_by_user_code(user_code : String) : DeviceCodeData?
+ abstract def update(device_code : String, data : DeviceCodeData)
+ abstract def delete(device_code : String)
+end
+
+module Authly::AttemptLimiter
+ abstract def exceeded?(key : String, limit : Int32) : Bool
+ abstract def increment(key : String, window : Time::Span) : Int32
+ def record(key : String, window : Time::Span)
+end
+```
+{% endtab %}
+{% endtabs %}
+
+---
+
+## Cryptographic Challenge Verifier (`Authly::ChallengeVerifier`)
+
+```crystal title="src/authly/challenge_verifier.cr"
+module Authly::ChallengeVerifier
+ def self.verify?(
+ challenge : String,
+ response : String,
+ public_key : String? = nil,
+ secret : String? = nil,
+ algorithm : String? = nil,
+ ) : Bool
+
+ def self.verify_hmac?(
+ challenge : String,
+ response : String,
+ secret : String,
+ digest_algorithm : OpenSSL::Algorithm = OpenSSL::Algorithm::SHA256,
+ ) : Bool
+
+ def self.verify_asymmetric?(
+ challenge : String,
+ response : String,
+ public_key : String,
+ algorithm : String? = nil,
+ ) : Bool
+end
+```
+
+---
+
+## Resource Server Handlers & Claims
+
+### `Authly::Handler::Bearer`
+```crystal title="src/authly/handlers/bearer_handler.cr"
+Authly::Handler::Bearer.new(
+ verifier : TokenVerifier = StrategyTokenVerifier.new,
+ realm : String = "authly",
+ prefixes : Enumerable(String)? = nil,
+ except : Enumerable(String) = [] of String,
+ except_exact : Enumerable(String) = [] of String,
+ allow_query_token : Bool = false,
+ pass_preflight : Bool = false,
+)
+```
+
+### `Authly::Handler::RequireScope`
+```crystal title="src/authly/handlers/require_scope_handler.cr"
+Authly::Handler::RequireScope.all(*scopes : String, realm : String = "authly", prefixes = nil, except = [] of String, except_exact = [] of String, pass_preflight : Bool = false)
+Authly::Handler::RequireScope.any(*scopes : String, realm : String = "authly", prefixes = nil, except = [] of String, except_exact = [] of String, pass_preflight : Bool = false)
+```
+
+### `Authly::Claims`
+Retrieved via `context.authly_claims` (`Authly::Claims?`) or `context.authly_claims!` (`Authly::Claims`):
+- `claims.subject : String`
+- `claims.client_id : String`
+- `claims.scopes : Array(String)`
+- `claims.scope?(name : String) : Bool`
+- `claims[claim : String] : JSON::Any`
+- `claims[claim : String]? : JSON::Any?`
diff --git a/docs/reference/http-endpoints.md b/docs/reference/http-endpoints.md
new file mode 100644
index 0000000..a8a0fe1
--- /dev/null
+++ b/docs/reference/http-endpoints.md
@@ -0,0 +1,160 @@
+---
+description: Complete HTTP wire protocol specification for every endpoint mounted by Authly::Handler.
+icon: globe
+---
+
+# HTTP Endpoints & Wire Protocol
+
+`Authly::Handler` routes the following OAuth2 and OpenID Connect endpoints. All other paths fall through to the next handler in the `HTTP::Server` chain.
+
+---
+
+## Endpoint Summary
+
+| Method(s) | Path | Handler | Specification |
+| :--- | :--- | :--- | :--- |
+| `GET`, `POST` | `/oauth/authorize` | `Authly::AuthorizationHandler` | RFC 6749, OIDC Core, RFC 7636 (PKCE), RFC 9101 (JARM) |
+| `POST` | `/oauth/token` | `Authly::AccessTokenHandler` / `Authly::RefreshTokenHandler` | RFC 6749, RFC 8628, Challenge-Response |
+| `POST` | `/oauth/par` | `Authly::PARHandler` | RFC 9126 (Pushed Authorization Requests) |
+| `POST` | `/oauth/challenge` | `Authly::ChallengeHandler` | Challenge-Response Nonce Issuance |
+| `POST` | `/oauth/device/code` | `Authly::DeviceAuthorizationHandler` | RFC 8628 Section 3.1 |
+| `GET`, `POST` | `/device` | `Authly::DeviceVerificationHandler` | RFC 8628 Section 3.3 |
+| `POST` | `/oauth/introspect`, `/introspect` | `Authly::IntrospectHandler` | RFC 7662 (Token Introspection) |
+| `POST` | `/oauth/revoke`, `/revoke` | `Authly::RevokeHandler` | RFC 7009 (Token Revocation) |
+| `GET` | `/oauth/userinfo` | `Authly::UserInfoHandler` | OIDC Core Section 5.3 |
+| `POST` | `/oauth/register` | `Authly::ClientRegistrationHandler` | RFC 7591 (Dynamic Client Registration) |
+| `GET`, `HEAD` | `/.well-known/openid-configuration` | `Authly::DiscoveryHandler` | OIDC Discovery 1.0 |
+| `GET`, `HEAD` | `/.well-known/oauth-authorization-server` | `Authly::DiscoveryHandler` | RFC 8414 |
+| `GET`, `HEAD` | `/.well-known/jwks.json` | `Authly::JWKSHandler` | RFC 7517 (JSON Web Key Set) |
+
+---
+
+## 1. `GET` & `POST /oauth/authorize`
+
+{% tabs %}
+{% tab title="GET /oauth/authorize" %}
+### Query Parameters
+- `response_type` (required unless `request_uri` is used): `"code"` or `"token"`.
+- `client_id` (required): Registered client identifier.
+- `redirect_uri` (required unless `request_uri` is used): Exact registered callback URI.
+- `scope` (required unless `request_uri` is used): Space-delimited scopes.
+- `state` (recommended): Opaque CSRF value echoed back on redirect.
+- `nonce` (optional): OpenID Connect replay protection value echoed in `id_token`.
+- `code_challenge` (optional, or required when `enforce_pkce = true`): PKCE challenge.
+- `code_challenge_method` (optional): `"S256"` or `"plain"`.
+- `request_uri` (optional, or required when `require_par = true`): PAR URI from `POST /oauth/par`.
+- `response_mode` (optional): `"query"`, `"fragment"`, `"form_post"`, `"query.jwt"`, `"fragment.jwt"`, `"form_post.jwt"`, or `"jwt"`.
+{% endtab %}
+
+{% tab title="POST /oauth/authorize" %}
+### Form Parameters
+- `consent_token` (required): Single-use signed token rendered on the consent form.
+- `decision` (required): `"approve"` or `"deny"`.
+{% endtab %}
+{% endtabs %}
+
+---
+
+## 2. `POST /oauth/challenge`
+
+Issues a cryptographically random, single-use challenge bound to `client_id`. Accepts `application/x-www-form-urlencoded`, `application/json`, and optional HTTP Basic client authentication.
+
+### Request Parameters
+- `client_id` (required): Registered client identifier.
+- `client_secret` (optional): Client secret (verified if provided).
+- `username` (optional): Resource owner username.
+- `owner_id` (optional): Resource owner ID.
+- `key_id` (optional): Cryptographic key identifier.
+
+### Success Response (`200 OK`, `Cache-Control: no-store`)
+```json
+{
+ "challenge_id": "4f8c1d7e2a9b0c3d5e6f7a8b9c0d1e2f",
+ "challenge": "a3f19b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a",
+ "expires_in": 60
+}
+```
+
+---
+
+## 3. `POST /oauth/token`
+
+Exchanges an authorization grant for an `AccessToken`.
+
+### Client Authentication
+- `client_secret_basic` (`Authorization: Basic base64(client_id:client_secret)`)
+- `client_secret_post` (`client_id` and `client_secret` in form body)
+- For `challenge_response` / `urn:ietf:params:oauth:grant-type:challenge`: `client_id` alone is accepted when authenticating via asymmetric or HMAC challenge response.
+
+### Parameters by `grant_type`
+
+| `grant_type` | Required Parameters | Optional Parameters |
+| :--- | :--- | :--- |
+| `authorization_code` | `code`, `redirect_uri`, `client_id`, `client_secret` | `code_verifier` |
+| `client_credentials` | `client_id`, `client_secret` | `scope` |
+| `password` | `username`, `password`, `client_id`, `client_secret` | `scope` |
+| `refresh_token` | `refresh_token`, `client_id`, `client_secret` | `scope` (may only narrow original scope) |
+| `urn:ietf:params:oauth:grant-type:device_code` | `device_code`, `client_id`, `client_secret` | — |
+| `challenge_response` / `urn:ietf:params:oauth:grant-type:challenge` | `client_id`, `challenge_id`, `response` | `client_secret`, `scope`, `key_id`, `algorithm` |
+
+### Success Response (`200 OK`, `Cache-Control: no-store`)
+```json
+{
+ "sub": "user-123",
+ "jti": "9d8e7f6a...",
+ "access_token": "eyJ0eXAiOiJKV1Qi...",
+ "token_type": "Bearer",
+ "scope": "read write",
+ "expires_in": 3600,
+ "refresh_token": "eyJ0eXAiOiJKV1Qi...",
+ "id_token": "eyJ0eXAiOiJKV1Qi..."
+}
+```
+
+{% hint style="info" %}
+`refresh_token` and `id_token` are omitted from the JSON payload when `nil`.
+{% endhint %}
+
+---
+
+## 4. `POST /oauth/par`
+
+Requires client authentication (`client_secret_basic` or `client_secret_post`). Accepts authorization request parameters (`response_type`, `redirect_uri`, `scope`, `state`, `code_challenge`, `code_challenge_method`, `nonce`) and returns `201 Created`:
+
+```json
+{
+ "request_uri": "urn:ietf:params:oauth:request_uri:...",
+ "expires_in": 90
+}
+```
+
+---
+
+## 5. `POST /oauth/introspect` & `POST /oauth/revoke`
+
+Both endpoints require client authentication (`client_secret_basic` or `client_secret_post`).
+
+{% tabs %}
+{% tab title="POST /oauth/introspect" %}
+- Form param: `token` (required).
+- Returns `200 OK` with `{"active": true, "scope": "...", "cid": "...", "exp": 1700000000, "sub": "..."}` or `{"active": false}`.
+{% endtab %}
+
+{% tab title="POST /oauth/revoke" %}
+- Form param: `token` (required).
+- Revokes the token (and its refresh family if it is a refresh token) only if the token was issued to the authenticating client; always returns `200 OK` per RFC 7009.
+{% endtab %}
+{% endtabs %}
+
+---
+
+## 6. `GET /oauth/userinfo`
+
+Requires `Authorization: Bearer `. Returns claims filtered by the access token's granted scopes (`openid`, `profile`, `email`, `address`, `phone`).
+
+---
+
+## 7. `GET /.well-known/openid-configuration` & `GET /.well-known/jwks.json`
+
+- `/.well-known/openid-configuration` and `/.well-known/oauth-authorization-server`: Return server metadata including `authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`, `jwks_uri`, `registration_endpoint`, `revocation_endpoint`, `introspection_endpoint`, `device_authorization_endpoint`, `pushed_authorization_request_endpoint`, and `challenge_endpoint`.
+- `/.well-known/jwks.json`: Returns `{"keys": [...]}` for asymmetric algorithms (`RS*`, `ES*`), or `{"keys": []}` for symmetric HMAC algorithms.
diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md
new file mode 100644
index 0000000..4b1ba7d
--- /dev/null
+++ b/docs/tutorials/README.md
@@ -0,0 +1,20 @@
+---
+description: Hands-on lessons to get you productive with Authly from scratch.
+icon: graduation-cap
+---
+
+# Tutorials
+
+These step-by-step tutorials guide you through building working authentication flows in Crystal. Each tutorial is self-contained and produces a runnable application.
+
+{% content-ref url="getting-started.md" %}
+[getting-started.md](getting-started.md)
+{% endcontent-ref %}
+
+{% content-ref url="authorization-code-pkce.md" %}
+[authorization-code-pkce.md](authorization-code-pkce.md)
+{% endcontent-ref %}
+
+{% content-ref url="challenge-response-auth.md" %}
+[challenge-response-auth.md](challenge-response-auth.md)
+{% endcontent-ref %}
diff --git a/docs/tutorials/authorization-code-pkce.md b/docs/tutorials/authorization-code-pkce.md
new file mode 100644
index 0000000..14dc9c7
--- /dev/null
+++ b/docs/tutorials/authorization-code-pkce.md
@@ -0,0 +1,145 @@
+---
+description: Authenticate human resource owners with Authorization Code, PKCE (S256), and OpenID Connect ID Tokens.
+icon: user-lock
+---
+
+# Interactive Sign-In with PKCE & OIDC
+
+In this tutorial, you will configure an interactive **Authorization Code with PKCE (`S256`)** flow and **OpenID Connect (`openid`)** ID token issuance in Authly.
+
+{% hint style="warning" %}
+**Host-Owned Login Boundary**
+Authly never reads `user_id` or `consent` from request parameters. Your host application supplies the signed-in user's ID exclusively via `config.authenticate_user`. While `authenticate_user` is unset, `/oauth/authorize` and `/device` refuse every request.
+{% endhint %}
+
+```mermaid
+sequenceDiagram
+ participant Browser
+ participant Client as Web App Client
+ participant Authly as Authly (/oauth/authorize & /oauth/token)
+
+ Client->>Client: Generate code_verifier & S256 code_challenge
+ Client->>Browser: Redirect to GET /oauth/authorize?code_challenge=...
+ Browser->>Authly: GET /oauth/authorize (with host session)
+ Authly-->>Browser: Render CSP-protected Consent Page (with single-use consent_token)
+ Browser->>Authly: POST /oauth/authorize (decision=approve, consent_token)
+ Authly-->>Browser: 302 Redirect to redirect_uri?code=...&state=...
+ Browser->>Client: Deliver authorization code
+ Client->>Authly: POST /oauth/token (code + code_verifier)
+ Authly-->>Client: 200 OK {access_token, id_token, refresh_token}
+```
+
+---
+
+{% stepper %}
+{% step %}
+### Configure PKCE Enforcement & Session Authentication
+
+Configure Authly to enforce PKCE (`S256`) and resolve the signed-in user from your application's session:
+
+```crystal title="pkce_tutorial.cr" lineNumbers="true"
+require "http/server"
+require "digest/sha256"
+require "base64"
+require "authly"
+
+Authly.configure do |config|
+ config.issuer = "http://localhost:8080"
+ config.base_url = "http://localhost:8080"
+ config.secret_key = "tutorial-secret-key-at-least-32-bytes"
+
+ # Enforce PKCE with S256 on all authorization_code requests
+ config.enforce_pkce = true
+ config.enforce_pkce_s256 = true
+
+ # Resolve the signed-in user from the host application's session
+ config.authenticate_user = ->(ctx : HTTP::Server::Context) {
+ ctx.request.headers["X-Session-User"]?.presence
+ }
+end
+
+# Register a client and a resource owner
+Authly.clients << Authly::Client.new(
+ name: "Web App",
+ secret: "webapp-secret",
+ redirect_uri: "http://localhost:3000/callback",
+ id: "webapp",
+ scopes: "openid profile read",
+ grant_types: "authorization_code refresh_token",
+)
+
+Authly.owners << Authly::Owner.new("alice", "password123")
+```
+{% endstep %}
+
+{% step %}
+### Generate a PKCE Verifier & `S256` Challenge
+
+Before redirecting the user to `/oauth/authorize`, the client generates a high-entropy `code_verifier` and computes its URL-safe Base64 SHA-256 hash (`code_challenge`):
+
+```crystal title="pkce_tutorial.cr"
+code_verifier = Random::Secure.hex(32)
+code_challenge = Base64.urlsafe_encode(
+ Digest::SHA256.digest(code_verifier),
+ padding: false
+)
+```
+{% endstep %}
+
+{% step %}
+### Issue and Exchange the Authorization Code
+
+Once the user approves the consent screen at `/oauth/authorize`, Authly issues a signed, single-use authorization code bound to `client_id`, `redirect_uri`, `code_challenge`, `user_id`, and `nonce`. Exchange it at `POST /oauth/token`:
+
+{% tabs %}
+{% tab title="Crystal API" %}
+```crystal title="pkce_tutorial.cr"
+code = Authly::Code.new(
+ client_id: "webapp",
+ scope: "openid read",
+ redirect_uri: "http://localhost:3000/callback",
+ challenge: code_challenge,
+ method: "S256",
+ user_id: "alice",
+ nonce: "n-0S6_WzA2Mj",
+).to_s
+
+token = Authly.access_token(
+ "authorization_code",
+ client_id: "webapp",
+ client_secret: "webapp-secret",
+ redirect_uri: "http://localhost:3000/callback",
+ code: code,
+ verifier: code_verifier,
+)
+
+puts "Access Token: #{token.access_token}"
+puts "ID Token: #{token.id_token}"
+puts "Subject: #{token.sub}"
+```
+{% endtab %}
+
+{% tab title="HTTP Wire Request" %}
+```http
+POST /oauth/token HTTP/1.1
+Content-Type: application/x-www-form-urlencoded
+
+grant_type=authorization_code&
+client_id=webapp&
+client_secret=webapp-secret&
+redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&
+code=&
+code_verifier=
+```
+{% endtab %}
+{% endtabs %}
+{% endstep %}
+{% endstepper %}
+
+---
+
+## Next Steps
+
+{% content-ref url="../how-to/fapi-and-advanced-security.md" %}
+[fapi-and-advanced-security.md](../how-to/fapi-and-advanced-security.md)
+{% endcontent-ref %}
diff --git a/docs/tutorials/challenge-response-auth.md b/docs/tutorials/challenge-response-auth.md
new file mode 100644
index 0000000..35dfd7a
--- /dev/null
+++ b/docs/tutorials/challenge-response-auth.md
@@ -0,0 +1,170 @@
+---
+description: Authenticate clients and resource owners using cryptographic challenge-response signatures (HMAC-SHA256, Ed25519, RSA, ECDSA).
+icon: key
+---
+
+# Cryptographic Challenge-Response Auth
+
+In this tutorial, you will authenticate a client and a resource owner using Authly's **Challenge-Response** flow (`POST /oauth/challenge` and `POST /oauth/token` with `grant_type=challenge_response` or `urn:ietf:params:oauth:grant-type:challenge`).
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Challenge as POST /oauth/challenge
+ participant Token as POST /oauth/token
+
+ Client->>Challenge: {client_id, username / owner_id / key_id}
+ Challenge-->>Client: 200 OK {challenge_id, challenge, expires_in: 60}
+ Note over Client: Sign challenge locally with Ed25519 / RSA / ECDSA / HMAC-SHA256
+ Client->>Token: grant_type=challenge_response, client_id, challenge_id, response
+ Token-->>Client: 200 OK {access_token, token_type, expires_in, refresh_token}
+```
+
+---
+
+{% stepper %}
+{% step %}
+### Configure Authly & Register Cryptographic Keys
+
+Configure `challenge_ttl` (`60.seconds` by default) and `challenge_max_attempts` (`1` by default) and register an owner or client with an asymmetric public key or HMAC secret:
+
+```crystal title="challenge_demo.cr" lineNumbers="true"
+require "authly"
+require "ed25519"
+require "openssl/hmac"
+
+Authly.configure do |config|
+ config.issuer = "https://auth.example.com"
+ config.secret_key = "demo-jwt-signing-secret-key"
+ config.challenge_ttl = 60.seconds
+ config.challenge_max_attempts = 1
+end
+
+# 1. Register a client authorized for challenge_response
+Authly.clients << Authly::Client.new(
+ name: "CLI Client",
+ secret: "cli-shared-hmac-secret",
+ redirect_uri: "https://cli.example.com/callback",
+ id: "cli-client",
+ scopes: "read write",
+ grant_types: "challenge_response urn:ietf:params:oauth:grant-type:challenge refresh_token",
+)
+
+# 2. Register an owner with an Ed25519 public key
+signing_key = Ed25519::SigningKey.new
+public_key_hex = signing_key.verify_key.key_bytes.hexstring
+
+Authly.owners << Authly::Owner.new(
+ username: "alice",
+ password: "",
+ public_key: public_key_hex,
+ key_id: "alice-ed25519-key",
+ id: "user-alice-01",
+)
+```
+{% endstep %}
+
+{% step %}
+### Request a Single-Use Challenge (`POST /oauth/challenge`)
+
+Request a challenge nonce bound to `client_id` and optional `username`, `owner_id`, or `key_id`:
+
+{% tabs %}
+{% tab title="HTTP JSON Request" %}
+```http
+POST /oauth/challenge HTTP/1.1
+Content-Type: application/json
+
+{
+ "client_id": "cli-client",
+ "username": "alice",
+ "key_id": "alice-ed25519-key"
+}
+```
+
+Response (`200 OK`):
+
+```json
+{
+ "challenge_id": "4f8c1d7e2a9b0c3d5e6f7a8b9c0d1e2f",
+ "challenge": "a3f19b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a",
+ "expires_in": 60
+}
+```
+{% endtab %}
+
+{% tab title="Crystal API" %}
+```crystal
+challenge_data = Authly::ChallengeResponse.create_challenge(
+ client_id: "cli-client",
+ username: "alice",
+ key_id: "alice-ed25519-key",
+)
+```
+{% endtab %}
+{% endtabs %}
+{% endstep %}
+
+{% step %}
+### Sign the Challenge & Exchange for an Access Token (`POST /oauth/token`)
+
+Sign `challenge_data.challenge` using your chosen algorithm and exchange it at `/oauth/token`:
+
+{% tabs %}
+{% tab title="Ed25519" %}
+```crystal
+sig_bytes = signing_key.sign(challenge_data.challenge.to_slice)
+response_hex = sig_bytes.hexstring
+
+token = Authly.access_token(
+ "challenge_response",
+ client_id: "cli-client",
+ challenge_id: challenge_data.challenge_id,
+ response: response_hex,
+ scope: "read write",
+)
+```
+{% endtab %}
+
+{% tab title="RSA (OpenSSL::PKey::RSA)" %}
+```crystal
+rsa_key = OpenSSL::PKey::RSA.new(2048)
+sig_bytes = rsa_key.sign(OpenSSL::Digest.new("sha256"), challenge_data.challenge)
+response_b64 = Base64.strict_encode(sig_bytes)
+```
+{% endtab %}
+
+{% tab title="ECDSA (OpenSSL::PKey::EC)" %}
+```crystal
+ec_key = OpenSSL::PKey::EC.generate("P-256")
+digest = OpenSSL::Digest.new("sha256").update(challenge_data.challenge).final
+sig_bytes = ec_key.ec_sign(digest)
+response_b64 = Base64.strict_encode(sig_bytes)
+```
+{% endtab %}
+
+{% tab title="HMAC-SHA256" %}
+```crystal
+response_hex = OpenSSL::HMAC.hexdigest(
+ :sha256,
+ "cli-shared-hmac-secret",
+ challenge_data.challenge
+)
+```
+{% endtab %}
+{% endtabs %}
+{% endstep %}
+{% endstepper %}
+
+{% hint style="success" %}
+**Replay & Brute-Force Protection Built In**
+With `config.challenge_max_attempts = 1` (the default), every challenge is atomically consumed on the first verification attempt—whether the signature succeeds or fails. Replaying a consumed challenge returns `400 invalid_grant`, and presenting an expired challenge returns `400 expired_challenge`.
+{% endhint %}
+
+---
+
+## Next Steps
+
+{% content-ref url="../how-to/custom-stores-and-models.md" %}
+[custom-stores-and-models.md](../how-to/custom-stores-and-models.md)
+{% endcontent-ref %}
diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md
new file mode 100644
index 0000000..b6d3724
--- /dev/null
+++ b/docs/tutorials/getting-started.md
@@ -0,0 +1,235 @@
+---
+description: Build your first Crystal OAuth2 Authorization Server and Bearer-protected Resource API in 10 minutes.
+icon: rocket
+---
+
+# Getting Started: First OAuth2 Server & API
+
+In this tutorial, you will build a single Crystal HTTP server that acts as both an **OAuth2 Authorization Server** and a **Protected Resource API**. By the end, you will issue a JWT access token using the `client_credentials` grant and use it to call a scope-protected `/orders` endpoint.
+
+{% hint style="info" %}
+**Prerequisites**
+- Crystal `>= 1.22.0` (`crystal --version`)
+- `curl` (for testing HTTP endpoints)
+{% endhint %}
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Authly as Authly::Handler (/oauth/token)
+ participant Bearer as Authly::Handler::Bearer
+ participant API as /orders Endpoint
+
+ Client->>Authly: POST /oauth/token (client_credentials, scope=orders:read)
+ Authly-->>Client: 200 OK {access_token, refresh_token, expires_in}
+ Client->>Bearer: GET /orders (Authorization: Bearer )
+ Bearer->>API: Forward verified context.authly_claims!
+ API-->>Client: 200 OK {orders: [...]}
+```
+
+---
+
+{% stepper %}
+{% step %}
+### Create the Crystal Project & Install Authly
+
+Initialize a new Crystal project and add `authly` to `shard.yml`:
+
+{% tabs %}
+{% tab title="Terminal" %}
+```bash
+mkdir authly_demo && cd authly_demo
+crystal init app authly_demo
+```
+{% endtab %}
+
+{% tab title="shard.yml" %}
+```yaml title="shard.yml"
+name: authly_demo
+version: 0.1.0
+
+dependencies:
+ authly:
+ github: azutoolkit/authly
+```
+{% endtab %}
+{% endtabs %}
+
+Install dependencies:
+
+```bash
+shards install
+```
+{% endstep %}
+
+{% step %}
+### Configure Authly & Register a Client
+
+Replace `src/authly_demo.cr` with the following server implementation. We mount three handlers in `HTTP::Server`:
+1. `Authly::Handler` — Serves `/oauth/*` and `/.well-known/*` endpoints.
+2. `Authly::Handler::Bearer` — Verifies Bearer tokens on all routes except `/health`, `/oauth/*`, and `/.well-known/*`.
+3. `Authly::Handler::RequireScope` — Requires the `orders:read` scope on `/orders`.
+
+```crystal title="src/authly_demo.cr" lineNumbers="true"
+require "http/server"
+require "json"
+require "authly"
+
+# 1. Configure the Authorization Server
+Authly.configure do |config|
+ config.issuer = "http://localhost:8080"
+ config.base_url = "http://localhost:8080"
+ config.secret_key = "super-secret-signing-key-for-tutorial-only"
+ config.token_strategy = :jwt
+ config.algorithm = JWT::Algorithm::HS256
+ config.access_ttl = 15.minutes
+end
+
+# 2. Register an OAuth2 Client
+Authly.clients << Authly::Client.new(
+ name: "Orders Service",
+ secret: "orders-client-secret",
+ redirect_uri: "http://localhost:8080/callback",
+ id: "orders-service",
+ scopes: "orders:read orders:write",
+ grant_types: "client_credentials refresh_token",
+)
+
+# 3. Define the Resource Server application handler
+app = HTTP::Handler::HandlerProc.new do |context|
+ case context.request.path
+ when "/health"
+ context.response.content_type = "application/json"
+ context.response.print({status: "ok"}.to_json)
+ when "/orders"
+ claims = context.authly_claims!
+ context.response.content_type = "application/json"
+ context.response.print({
+ client_id: claims.client_id,
+ subject: claims.subject,
+ scopes: claims.scopes,
+ orders: [{id: 101, total: "49.99"}],
+ }.to_json)
+ else
+ context.response.status_code = 404
+ context.response.print("Not Found")
+ end
+end
+
+# 4. Assemble the HTTP::Server handler chain
+server = HTTP::Server.new([
+ Authly::Handler.new,
+ Authly::Handler::Bearer.new(
+ realm: "orders-api",
+ except_exact: ["/health"],
+ except: ["/oauth", "/.well-known"],
+ ),
+ Authly::Handler::RequireScope.all("orders:read", prefixes: ["/orders"]),
+ app,
+])
+
+address = server.bind_tcp("127.0.0.1", 8080)
+puts "Listening on http://#{address}"
+server.listen
+```
+
+Start the server:
+
+```bash
+crystal run src/authly_demo.cr
+```
+{% endstep %}
+
+{% step %}
+### Verify Unprotected and Protected Routes
+
+Test `/health` (exempted via `except_exact`) and `/orders` (protected by `Bearer`):
+
+{% tabs %}
+{% tab title="GET /health (200 OK)" %}
+```bash
+curl -i http://127.0.0.1:8080/health
+```
+
+```http
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+{"status":"ok"}
+```
+{% endtab %}
+
+{% tab title="GET /orders without token (401)" %}
+```bash
+curl -i http://127.0.0.1:8080/orders
+```
+
+```http
+HTTP/1.1 401 Unauthorized
+WWW-Authenticate: Bearer realm="orders-api"
+Cache-Control: no-store
+Content-Type: application/json
+
+{}
+```
+{% endtab %}
+{% endtabs %}
+{% endstep %}
+
+{% step %}
+### Issue an Access Token & Call `/orders`
+
+Request an access token from `POST /oauth/token` and pass it in the `Authorization: Bearer` header:
+
+{% tabs %}
+{% tab title="1. Issue Token (cURL)" %}
+```bash
+curl -s -X POST http://127.0.0.1:8080/oauth/token \
+ -u "orders-service:orders-client-secret" \
+ -d "grant_type=client_credentials&scope=orders:read"
+```
+
+```json
+{
+ "sub": "orders-service",
+ "jti": "9a8b7c6d...",
+ "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
+ "token_type": "Bearer",
+ "scope": "orders:read",
+ "expires_in": 900,
+ "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
+}
+```
+{% endtab %}
+
+{% tab title="2. Call Protected Endpoint (cURL)" %}
+```bash
+TOKEN=""
+curl -s http://127.0.0.1:8080/orders \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+```json
+{
+ "client_id": "orders-service",
+ "subject": "orders-service",
+ "scopes": ["orders:read"],
+ "orders": [{"id": 101, "total": "49.99"}]
+}
+```
+{% endtab %}
+{% endtabs %}
+{% endstep %}
+{% endstepper %}
+
+---
+
+## Next Steps
+
+{% content-ref url="authorization-code-pkce.md" %}
+[authorization-code-pkce.md](authorization-code-pkce.md)
+{% endcontent-ref %}
+
+{% content-ref url="challenge-response-auth.md" %}
+[challenge-response-auth.md](challenge-response-auth.md)
+{% endcontent-ref %}