OAuth 2.0 / 2.1
RESTHeart CloudConfiguration
The URL and credentials the examples on this page use
Overview
RESTHeart provides a standards-compliant OAuth 2.0/2.1 token endpoint and authorization server. It supports four grant types and the full Authorization Code + PKCE flow for delegated user authentication.
|
Note
|
In all examples below, replace |
Available endpoints:
| Endpoint | Enabled by default | Description |
|---|---|---|
|
yes |
Issues a JWT access token. Supports |
|
yes |
Issues a JWT and sets it as an HttpOnly cookie (browser apps). |
|
yes |
Returns the current token for the authenticated user. |
|
yes |
Since RESTHeart 9.5. Returns the token in the body and redirects to a configured URL with the token appended as a URL fragment ( |
|
yes |
Invalidates the current token. |
|
no |
Starts the Authorization Code + PKCE flow โ redirects to the configured login page. |
|
no |
Completes the Authorization Code flow โ issues the authorization code after successful login. |
|
no |
The social providers this deployment can sign a person in with, for the sign-in page to offer. |
|
no |
Authorization Server Metadata (RFC 8414) โ for automatic client discovery. |
|
no |
Protected Resource Metadata (RFC 9728) โ advertises the authorization server to MCP clients. |
Token Endpoint
authTokenService is enabled by default and bound to POST /token.
Password Grant
Authenticate with username and password using HTTP Basic Auth:
cURL
curl -i -X POST [RESTHEART-URL]/token \
-u [BASIC-AUTH]
HTTPie
http POST [RESTHEART-URL]/token \
Authorization:"Basic [BASIC-AUTH]"
Or using OAuth 2.0 form data (grant_type=password, RFC 6749 ยง4.3):
HTTPie
http -f POST [RESTHEART-URL]/token \
grant_type=password \
username=admin \
password=secret
Response:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 900,
"username": "admin",
"roles": ["admin"]
}
Client Credentials Grant
|
Note
|
Available from RESTHeart v9.2.0. |
For machine-to-machine authentication, use grant_type=client_credentials (RFC 6749 ยง4.4).
The client_id and client_secret map to user credentials in the configured authenticator.
HTTPie
http -f POST [RESTHEART-URL]/token \
grant_type=client_credentials \
client_id=myapp \
client_secret=s3cret
Using the Token
Once you have the token, use it as a Bearer token:
cURL
curl -i -X GET [RESTHEART-URL]/mycollection \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
HTTPie
http GET [RESTHEART-URL]/mycollection \
"Authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
Token Renewal
There is one token, and renewing it means asking for a new one with the one you have. Two ways to do it โ pick by what your client already speaks.
With ?renew, on a token that has not expired yet:
http GET [RESTHEART-URL]/token?renew \
Authorization:"Bearer <current-token>"
With grant_type=refresh_token, which is what an OAuth client does. The value you send is the token itself โ RESTHeart does not issue a separate refresh token:
http -f POST [RESTHEART-URL]/token \
grant_type=refresh_token \
refresh_token=<current-token>
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
refresh_token in the response is the same string as access_token, so a client that stores the two separately keeps working.
The renewed token is built from the account read again from the users collection, so it carries current roles and claims. A user disabled in the meantime stops renewing.
|
Caution
|
grant_type=refresh_token is available since RESTHeart 9.9.0.
|
The grace window
?renew requires a valid token. grant_type=refresh_token also accepts one that expired recently, because OAuth clients typically renew after a 401 rather than before expiry:
authTokenService:
refresh-grace-seconds: 300
Only renewal accepts an expired token; it opens and reads nothing.
Five minutes is sized for the client that went idle. A busy client gets its 401 within seconds of expiry and renews immediately; the one that actually meets an expired token stopped calling for a while, so this window sets how long it may stay away and still resume without sending the user through the sign-in flow again.
Set it together with ttl, and read the sum. The window extends the power to renew, not to read โ but a renewal yields a token that reads, so moving seconds between the two changes little. What ttl + refresh-grace-seconds measures is revocation latency: renewal re-reads the account, so a user you disable keeps working for at most that long. At the defaults, twenty minutes.
Bounding the renewal chain
A renewed token is renewable in turn, so ttl is not a limit on how long a token stays useful. Two claims let you set one without writing a plugin:
| Claim | Meaning |
|---|---|
|
When the user actually authenticated, in epoch seconds. Carried through renewals unchanged. |
|
|
Refuse renewal past ten of them with an ACL permission:
{ "predicate": "path('/token') and method(POST) and lte(@user.renewals, 9)" }
Renewing with username and password starts a new chain: auth_time is now and renewals is back to 0.
|
Caution
|
Both claims are available since RESTHeart 9.9.0. A token issued before the upgrade carries neither, and lte treats an operand it cannot read as false โ such a token is refused renewal rather than granted it.
|
|
Note
|
Token generation is a cryptographic operation with some overhead. The client is responsible for renewing before expiry. |
Cookie-Based Authentication
For browser applications, use /token/cookie to set an HttpOnly cookie (the token is never exposed to JavaScript):
HTTPie
http --session=./session.json POST [RESTHEART-URL]/token/cookie \
Authorization:"Basic [BASIC-AUTH]"
Subsequent requests in the same session automatically include the cookie.
Redirect-Based Authentication
|
Note
|
Available from RESTHeart 9.5. |
Some flows have to end in a real browser navigation instead of an AJAX/fetch call โ for example, handing a session token to a single-page app after an OAuth-provider round trip, where there is no JSON response for the frontend’s JavaScript to read. GET /token/redirect covers this case: it returns the token in the response body (same shape as GET /token, for callers that don’t follow the redirect) and issues an HTTP redirect to a configured URL with the token appended as a URL fragment:
|
Note
|
Despite living in this OAuth-focused document, GET /token/redirect is not OAuth-specific โ it’s a general endpoint of AuthTokenService (like /token and /token/cookie), usable by any flow that authenticates via a real browser navigation rather than a fetch() call. An OAuth-provider callback is the canonical example, but the same mechanism applies just as well to an email-verification link, a signed "magic link," or any other flow where the client can’t read a JSON response body directly.
|
HTTPie
http --follow=false GET [RESTHEART-URL]/token/redirect \
Authorization:"Basic [BASIC-AUTH]"
Response:
HTTP/1.1 307 Temporary Redirect
Location: https://app.example.com/callback#access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...&token_type=Bearer&expires_in=899
The token is carried as a URL fragment (#access_token=…), not a query parameter, deliberately: a fragment is never sent to any server โ not on the redirect response itself, not on any subsequent request the browser makes to that URL, not in the Referer header. A query parameter, by contrast, travels with every future navigation from that URL and routinely ends up in server access logs, reverse-proxy logs, and analytics โ a real leak vector for a bearer token. This mirrors OAuth 2.0’s own long-established Implicit Flow convention (#access_token=…).
The frontend reads location.hash, extracts the token, and should clear the fragment (e.g. via history.replaceState) so it doesn’t linger in browser history.
Configuring the redirect target
The redirect target is never a request parameter โ accepting a caller-supplied redirect target here would be an open-redirect vector combined with a token leak (an attacker could craft a link to …/token/redirect?redirect=https://evil.com and, if a victim with a valid session clicked it, their token would end up in a redirect to the attacker’s site). It comes from configuration only:
authTokenService:
uri: /token
redirect-url: https://app.example.com/callback
For multi-tenant deployments where different requests should redirect to different frontends (e.g. one RESTHeart instance serving many independent services, each with its own frontend URL), a plugin can attach a per-request override that takes precedence over the static config:
request.attachParam("override-redirect-url", "https://tenant-a.example.com/callback");
If neither the static config nor the override is set, GET /token/redirect returns 400 Bad Request rather than falling back to something guessable.
Choosing How to Deliver the Token
Which of /token, /token/cookie, or /token/redirect to use is a sign-in process decision, not an OAuth-specific one โ see Choosing How to Deliver the Token in "How Clients Authenticate" for the full comparison (pros/cons, when to use each, and the synergy with originVetoer).
Authorization Code + PKCE Flow
|
Note
|
Available from RESTHeart v9.2.1. |
The OAuth 2.1 Authorization Code flow with PKCE (RFC 7636) allows a user to authenticate via a frontend login page. The access token is issued only after successful authentication โ no credentials are passed to the client.
This flow is the recommended approach for:
-
Web applications with a frontend login UI
-
MCP clients (e.g., Claude Desktop via
mcp-remote) -
CLI tools that open a browser for login
Flow overview:
Client RESTHeart Frontend login UI
| | |
|-- GET /authorize ------>| |
| (code_challenge, S256)| |
|<-- 302 to login-url ----| |
| |<-- POST /authorize -----|
| | username+password |
| |--(valid)- 302 callback?code=... ->|
| |--(invalid)- 302 login?error=... ->|
|<----------------------------------------------- code ---|
|-- POST /token ---------->|
| (code, code_verifier) |
|<-- access_token ---------|
Step 1: Start the authorization request
Generate a PKCE pair and redirect the user to GET /authorize:
# Generate PKCE pair
CODE_VERIFIER="dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | \
openssl base64 | tr '+/' '-_' | tr -d '=')
http --follow=false GET "[RESTHEART-URL]/authorize" \
"response_type==code" \
"client_id==my-app" \
"redirect_uri==https://myapp.example.com/callback" \
"code_challenge==${CODE_CHALLENGE}" \
"code_challenge_method==S256" \
"state==random-state-value"
RESTHeart responds with 302 to the configured login-url, forwarding all OAuth parameters as query string.
RESTHeart serves a sign-in page of its own at /oauth/login, and that is the default: enable oauthAuthorizationService and the flow works with no page to build. It posts the credentials as described in Step 2 and shows which application is asking, by the host it will return the user to. Set login-url to replace it with your own.
Where social sign-in is configured, the page also offers it. It asks GET /authorize/providers โ unauthenticated, since it is asked before anyone has signed in โ and renders a button per provider it names:
{ "providers": ["google"] }
The answer is per request, because providers are configured per tenant, and it is empty where none is: the page is then exactly the password form it was. The list comes from whatever implements OAuthProviderRegistry on the instance, found by type; with no such module there is nothing to offer.
Pressing a button leaves for /auth/oauth/authorize/<provider>, carrying returnTo set to this page with the OAuth parameters untouched. The provider’s callback signs the person in โ creating the account on a first sign-in, as it does anywhere else โ and sends the browser back to returnTo with the token in the URL fragment. The page reads it, erases it from the address bar, and completes the flow exactly as a password would: the same /authorize/offer call, the same submission, with the token in place of the credentials.
returnTo is refused unless it is a path on this host: not an absolute URL, and not //elsewhere, which a browser reads as another host. The value decides where a freshly issued token is delivered, so it is checked when the flow starts rather than sanitised when it ends.
The default is a relative path on purpose โ the browser resolves it against the origin it is already on, which keeps it right behind a TLS-terminating proxy where an absolute URL built from the Host header would come out as http://.
Step 2: User authenticates
The frontend presents the login form and POSTs the credentials to POST /authorize.
The OAuth parameters are forwarded as query string (exactly as received from the GET /authorize redirect).
Three credential formats are supported:
Option A โ form body (recommended for browser login pages):
RESTHeart reads username and password from the application/x-www-form-urlencoded body.
No Authorization header is needed โ the browser’s native form POST works as-is.
http --follow=false POST \
"[RESTHEART-URL]/authorize?response_type=code&client_id=my-app&\
redirect_uri=https://myapp.example.com/callback&\
code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256&state=random-state-value" \
username=admin \
password=secret
On invalid credentials RESTHeart redirects back to login-url?error=invalid_credentials&<original_query_params>
so the login page can show an error without losing the OAuth context.
Option B โ a bearer token in the form body, for a person the sign-in page just signed in through a social provider: RESTHeart reads access_token from the same form-encoded body and authenticates the request with it. Completing the flow has to be a browser navigation, because the answer is a 302 the browser must follow, and a navigation carries no headers โ so the token travels as a field.
Option C โ HTTP Basic Auth (for API clients / programmatic use):
http --follow=false -a admin:secret POST \
"[RESTHEART-URL]/authorize?response_type=code&client_id=my-app&\
redirect_uri=https://myapp.example.com/callback&\
code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256&state=random-state-value"
|
Note
|
If an Authorization: Basic header is present it always takes precedence over form body credentials.
|
Response (valid credentials): 302 to redirect_uri?code=<authorization_code>&state=<state>.
The authorization code is a short-lived JWT (5-minute TTL) signed with the shared jwtConfigProvider key.
It is stateless โ valid across all nodes in a cluster without shared storage.
Step 3: Exchange the code for an access token
http -f POST [RESTHEART-URL]/token \
grant_type=authorization_code \
code="<authorization_code>" \
redirect_uri="https://myapp.example.com/callback" \
client_id=my-app \
code_verifier="${CODE_VERIFIER}"
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 899
}
Deciding which token the flow issues
|
Note
|
Available from RESTHeart v9.9.0. |
By itself the flow has one outcome: /token mints the JWT of the account that signed in, with every role that account holds, renewable through grant_type=refresh_token without end. A client that signs the user in through the browser โ an MCP client, say โ therefore runs with everything the user can do, indefinitely, and there is nothing to revoke.
A deployment that has narrower, revocable credentials of its own โ API keys, scoped tokens โ can hand one of those out instead, and let the user choose something about it on the sign-in page before it exists: which role it carries, how long it lives. RESTHeart does not know what those credentials are. It knows the three moments at which to ask an OAuthTokenIssuer, and a deployment supplies one as a Provider:
@RegisterPlugin(name = "myTokenIssuer", description = "...")
public class MyTokenIssuer implements Provider<OAuthTokenIssuer> {
@Override
public OAuthTokenIssuer get(PluginRecord<?> caller) {
return new OAuthTokenIssuer() {
// 1. what the page may offer this account, once its credentials are verified
public Optional<Offer> offer(Account account, Request<?> request) throws Refusal { ... }
// 2. validate the choice; what comes back is sealed in the authorization code
public Map<String, Object> claims(Account account, Request<?> request, Map<String, String> choice) throws Refusal { ... }
// 3. mint the token from the sealed claims, at /token
public IssuedToken issue(Map<String, Object> claims, Account account, Request<?> request) throws Refusal { ... }
};
}
}
Registering one is the switch. With no issuer registered, the page, /authorize and /token behave exactly as described above. The OAuth services find the issuer by the type it provides, so no name has to be agreed; one is active per instance.
The choice step
The sign-in page RESTHeart serves has a second step, shown only when the issuer offers something:
-
the person enters username and password, or signs in with a social provider and comes back with a token; the page asks
GET /authorize/offerwith whichever it has, asBasiccredentials or aBearertoken, and the same query string it received; -
204: nothing to choose, the page submits the credentials at once.200: an offer, rendered as a second step โ a heading, a message, and the choices, each a select, a number or a text field.403: a refusal, shown and stopped at: this account obtains no token here.401: wrong password, so this call is also how the page checks it; -
the person chooses and the page posts to
/authorizeas in Step 2, the identity in the form body โusername/password, oraccess_tokenโ and the choices in the query string, onechoice.<name>parameter per choice.
A client that knows what it wants says so with the OAuth scope parameter, one <name>:<value> entry per choice โ scope=role:agent days:30. The page uses it to preselect, and /authorize applies it for any choice the page did not send. The issuer receives one map either way and validates it: nothing the page or the client sent is trusted.
A custom login-url follows the same contract: call GET /authorize/offer to learn what to render, send choice.<name> in the query string of the POST. A page that sends no choice gets whatever scope asked for, or the issuer’s defaults, or a refusal if the issuer needed an answer.
What the code carries
What claims() returns is sealed in the authorization code under the claim ti, next to the PKCE challenge and the account’s own claims. The code is signed, not encrypted, and travels in the redirect URL โ so an issuer never puts a secret there. From then on that code is the issuer’s: /token asks issue() for the token and never falls back to the JWT, not even if no issuer is registered when the code is presented โ a sealed code means "not the JWT", and answering it with one would hand out exactly the credential the issuer was there to replace.
What /token answers
The issuer’s token, as it gave it:
{
"access_token": "rhak_โฆ",
"token_type": "Bearer",
"expires_in": 7776000
}
refresh_token only if the issuer supplied one. A token that is not a JWT has nothing to renew through grant_type=refresh_token, which answers invalid_grant: when it expires, the client signs in again, and that is the intended lifecycle of a credential the user chose a duration for.
A refusal from claims() goes back to the sign-in page with error and error_description when the credentials came from its form, and to the client’s redirect_uri as an OAuth error otherwise โ the same two routes wrong credentials take. A refusal from issue() is a token error, 400 with error and error_description.
Configuration
All three OAuth endpoints are disabled by default and must be explicitly enabled. Enable them together for the full Authorization Code + PKCE flow:
oauthAuthorizationServerMetadataService:
enabled: true
# Optional: override scheme+host for metadata URLs (needed behind a TLS-terminating proxy)
# Falls back to the request Host header when null.
base-url: null # e.g. https://api.example.com
authorize-endpoint-uri: /authorize
oauthAuthorizationService:
enabled: true
# The login page. Defaults to /oauth/login, the page RESTHeart serves itself;
# set it only to use your own.
login-url: https://myapp.example.com/login
# Allowed redirect_uri values โ supports * wildcard
allowed-redirect-uris:
- https://myapp.example.com/callback
- http://localhost:* # for local development
oauthProtectedResourceMetadataService:
enabled: true
# Optional: same as base-url in oauthAuthorizationServerMetadataService
base-url: null # e.g. https://api.example.com
|
Important
|
The jwtTokenManager must be enabled (it is by default in RESTHeart v9). The authorization code is signed with the same key as regular JWT tokens, ensuring stateless multi-node operation.
|
Authorization Server Metadata (RFC 8414)
|
Note
|
Available from RESTHeart v9.2.0. Disabled by default from v9.2.1. |
oauthAuthorizationServerMetadataService exposes GET /.well-known/oauth-authorization-server per RFC 8414.
OAuth 2.0 clients (API gateways, MCP clients, CLI tools) can use this endpoint to auto-configure without hardcoded URLs.
http GET [RESTHEART-URL]/.well-known/oauth-authorization-server
{
"issuer": "https://api.example.com",
"authorization_endpoint": "https://api.example.com/authorize",
"token_endpoint": "https://api.example.com/token",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token", "password", "client_credentials"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"]
}
The endpoint is publicly accessible (no authentication required).
All URLs in the response share one base, resolved as for the protected resource metadata:
-
override-ai-mcp-public-base-url, when a multi-tenant deployment attaches it to the request -
the
base-urlconfiguration value -
X-Forwarded-Protofor the scheme andX-Forwarded-Hostfor the host, each when present -
the request itself
oauthAuthorizationServerMetadataService:
enabled: true
base-url: https://api.example.com # optional
authorize-endpoint-uri: /authorize # must match oauthAuthorizationService URI
registration-endpoint-uri: /register # optional, enables registration_endpoint in metadata
Protected Resource Metadata (RFC 9728)
|
Note
|
Available from RESTHeart v9.2.0. Disabled by default from v9.2.1. |
oauthProtectedResourceMetadataService exposes GET /.well-known/oauth-protected-resource per RFC 9728.
MCP clients (e.g., Claude Desktop via mcp-remote) use this endpoint to discover the authorization server URL before starting the OAuth flow.
http GET [RESTHEART-URL]/.well-known/oauth-protected-resource
{
"resource": "https://api.example.com",
"authorization_servers": ["https://api.example.com"]
}
A resource-specific path can be appended to scope the metadata:
http GET [RESTHEART-URL]/.well-known/oauth-protected-resource/mcp/ade
{
"resource": "https://api.example.com/mcp/ade",
"authorization_servers": ["https://api.example.com"]
}
The base-url option solves the common reverse-proxy problem where the Host header arrives as http:// while the public URL is https://, causing issuer validation failures per RFC 8414 ยง3.3:
oauthProtectedResourceMetadataService:
enabled: true
base-url: https://api.example.com # optional, resolves TLS proxy mismatch
Without base-url, the scheme comes from X-Forwarded-Proto and the host from X-Forwarded-Host, each when present, else from the request itself. A multi-tenant deployment can attach the tenant’s base URL to each request as override-ai-mcp-public-base-url, which wins over both. The Location of a created document has its own override, override-mongo-instance-base-url: see Where the Location points.
The challenge on a refused MCP request
|
Note
|
Available from RESTHeart v9.9.0. |
When a request to the MCP server is refused โ no credential, or a token or API key that is invalid, expired or revoked โ the 401 carries the location of this document:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="RESTHeart Realm"
WWW-Authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"
MCP 2025-06-18 requires it; clients of 2025-11-25 also find the document at the well-known URI without it. It is sent by oauthResourceChallengeMechanism, enabled by default, which never authenticates anybody and speaks only when oauthProtectedResourceMetadataService and mcpService are both enabled, and only on the MCP server’s path. The URL is built exactly as the document builds its own, so the two always name the same host. A request with No-Auth-Challenge gets no challenge, this one included.
Dynamic Client Registration (RFC 7591)
|
Note
|
Available from RESTHeart v9.3.0. Disabled by default. |
oauthClientRegistrationService exposes POST /register per RFC 7591, allowing OAuth clients to self-register without manual admin configuration.
This is required by tools such as mcp-inspector and MCP SDK clients that perform dynamic registration before starting the authorization flow.
http POST [RESTHEART-URL]/register \
redirect_uris:='["https://client.example.com/callback"]' \
client_name="My MCP Client" \
token_endpoint_auth_method=none
{
"client_id": "550e8400-e29b-41d4-a716-446655440000",
"client_id_issued_at": 1712345678,
"redirect_uris": ["https://client.example.com/callback"],
"client_name": "My MCP Client",
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code"],
"response_types": ["code"]
}
The endpoint is publicly accessible (no authentication required). The returned client_id is a UUID that clients must use in subsequent authorization requests.
|
Note
|
RESTHeart’s authorization service embeds client_id directly in the authorization code JWT without database validation. No client storage is required on the server side.
|
To enable dynamic client registration and advertise it in the AS metadata:
oauthClientRegistrationService:
enabled: true
oauthAuthorizationServerMetadataService:
enabled: true
registration-endpoint-uri: /register # adds registration_endpoint to discovery metadata
Token Manager Configuration
The JWT Token Manager is enabled by default in RESTHeart v9:
jwtTokenManager:
key: secret # Change this in production!
enabled: true
ttl: 15 # Token time-to-live in minutes
issuer: restheart.org
|
Important
|
Always set a strong, random key value in production environments.
|
The grace window for grant_type=refresh_token belongs to the token endpoint, not to the token manager:
authTokenService:
refresh-grace-seconds: 300 # how long past expiry a token may still be renewed