Authentication - OAuth
The MCP server includes a built-in OAuth 2.0 authorisation server. It lets a client sign a user in through a branded login page instead of handling a password or an API token itself, and it issues short-lived access tokens backed by the same user accounts described in Authentication - User Management.
When to Use OAuth
Use OAuth wherever a client can open a login page and does not want to store a long-lived credential of its own.
- Claude Desktop and the Claude mobile apps use OAuth when the server is added as a remote connector.
- The bundled CLI chat client uses OAuth by default in HTTP mode.
- The bundled web client uses OAuth when the server advertises it, falling back to username and password otherwise.
- Any other MCP client that supports OAuth discovery can use it too.
Machine-to-machine callers that cannot open a browser should continue to use an API token instead.
How the Flow Works
A client first fetches the server's metadata from
/.well-known/oauth-authorization-server, then either registers itself
dynamically or uses a preconfigured client ID. It sends the user's
browser to /oauth/authorize with a PKCE code challenge; the server
renders the branded login page, the user enters a username and
password, and the server redirects back to the client's redirect URI
with an authorisation code. The client exchanges that code, along with
the matching PKCE verifier, for an access token and a refresh token at
/oauth/token. A client with no browser, such as a headless CLI
session, uses the device authorisation grant instead: it polls
/oauth/token while the user completes the same login page on another
device.
Enabling OAuth
OAuth becomes active once authentication is enabled, the oauth method
has not been disabled, and an issuer URL is configured. The issuer is
the base URL clients use to discover and reach the authorisation
server; it usually matches the server's own public address.
In the following example, the oauth.issuer setting turns on the
authorisation server:
http:
auth:
enabled: true
oauth:
issuer: "https://mcp.example.com"
The issuer must be an absolute http or https URL with no query
string or fragment, and must use https unless the host is localhost
or a loopback address. Refer to
Specifying your Configuration Preferences for the
complete set of http.auth.oauth options and their defaults.
Redirect URIs
The server accepts three redirect URIs out of the box: the Claude.ai callback, and the loopback callbacks local tools use.
https://claude.ai/api/mcp/auth_callback
http://127.0.0.1/callback
http://localhost/callback
Listing anything in http.auth.oauth.allowed_redirect_uris replaces
that list rather than adding to it, so repeat any of the three you
still need alongside your own. The issuer's own
<issuer>/oauth/callback, and <origin>/oauth/callback for each
origin in http.allowed_origins, are accepted automatically and need
no entry here.
Dynamic client registration is enabled by default, and clients register
the redirect URI they will use, which must itself be one the server
accepts. Setting allow_dynamic_registration: false will only become
useful once the server supports configuring clients statically, which a
later release will add; until then it leaves no way for a client to
obtain a client ID at all.
Method Toggles
Each authentication method can be switched off independently under
http.auth.methods, leaving the other methods untouched.
http:
auth:
methods:
api_tokens: true
password_login: true
oauth: true
Every method defaults to true, so an existing configuration that
never mentions methods keeps working exactly as before. At least one
method must remain enabled while authentication itself is enabled.
Startup Output and Discovery
With OAuth active, the server logs its issuer at startup, and adds the issuer's own origin to the browser origins it accepts:
Accepting browser requests from: loopback origins only (localhost, 127.0.0.1, ::1; any port)
OAuth authorisation server enabled, issuer http://localhost:8099
Starting MCP server in HTTP mode on 127.0.0.1:8099
Accepting browser requests from the OAuth issuer origin: http://localhost:8099
A client discovers the authorisation server by fetching
/.well-known/oauth-authorization-server, which returns a document
such as the following:
{
"issuer": "http://localhost:8099",
"authorization_endpoint": "http://localhost:8099/oauth/authorize",
"token_endpoint": "http://localhost:8099/oauth/token",
"registration_endpoint": "http://localhost:8099/oauth/register",
"device_authorization_endpoint": "http://localhost:8099/oauth/device",
"revocation_endpoint": "http://localhost:8099/oauth/revoke",
"response_types_supported": ["code"],
"grant_types_supported": [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:device_code"
],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"scopes_supported": ["mcp"]
}
A request to a protected endpoint without a valid access token
receives a 401 response carrying a WWW-Authenticate header that
points the client at the protected resource metadata:
WWW-Authenticate: Bearer resource_metadata="http://localhost:8099/.well-known/oauth-protected-resource"
Claude Desktop and Claude Mobile
Add the server as a remote connector rather than a local stdio
server. In Claude Desktop, open Settings, choose Connectors, select Add
custom connector, and enter the server's URL. Claude Desktop opens the
server's login page in a browser; sign in there to complete the
connection. The Claude mobile apps share the same connector list once
it exists, so a connector added on the desktop appears on mobile
without repeating the steps. See
Configuring the Server for use with Claude Desktop
for the stdio route and this remote route side by side.
CLI Behaviour
The CLI chat client's auth_mode setting accepts auto, none,
token, user and oauth. The default, auto, tries OAuth first and
falls back to token or username and password authentication when the
server does not advertise OAuth.
By default, the CLI opens a browser and completes the authorisation
code grant through a loopback redirect. Pass --no-browser, or set
no_browser: true in the CLI configuration file, to use the device
authorisation grant instead; the CLI then prints a URL and a code for
the user to enter on another device. The /logout slash command
revokes the current OAuth session and clears its cached tokens.
The CLI caches OAuth tokens in a file named oauth-tokens.yaml,
stored beside its preferences file, and refreshes them automatically
as they approach expiry.
The CLI accepts OAuth only when the issuer the server advertises matches the URL the CLI was given, so where a deployment answers on more than one hostname, point the CLI at the issuer's own public URL.
In the following example, the -mcp-auth-mode flag and -no-browser
flag select the device authorisation grant:
./bin/pgedge-nla-cli -mcp-auth-mode oauth -no-browser \
-mcp-url https://mcp.example.com
Web Client Behaviour
The bundled web client discovers OAuth from the server's metadata and, when the server advertises it, shows a single Sign in button in place of the username and password form. Signing in redirects the browser to the server's login page and back to the web client's own origin.
The web client's origin must appear in http.allowed_origins, because
the server derives the OAuth redirect URI it accepts,
<origin>/oauth/callback, from each origin in that list. See
Browser Origins for the setting.
http:
allowed_origins:
- https://mcp.example.com
Device Consent Page
A client with no browser sends the user to /oauth/device/verify with
the code it was given. That page names the client that is asking and
the scope it asked for, before the user types anything, and offers two
buttons: Approve, which signs the user in and approves the request, and
Deny, which refuses it without asking for credentials. A denied request
makes the waiting client's next poll fail with access_denied, so it
stops polling rather than waiting for a timeout.
Branding
The login page's appearance comes from http.auth.oauth.login_page.
Every field is optional and falls back to the default shown below.
| Field | Description | Default |
|---|---|---|
title |
Form heading. | Sign in |
subtitle |
Text below the title. | See below |
message |
Notice paragraphs; a blank line starts a new one. | (none) |
footer |
Text below the form. | (none) |
logo_file |
Path to a custom logo, replacing the built-in one. | Built-in logo |
favicon_file |
Path to a custom favicon, replacing the built-in one. | Built-in favicon |
primary_colour |
CSS hex colour for buttons. | #15AABF |
secondary_colour |
CSS hex colour paired with the primary one. | #0C8599 |
template_file |
Path to a custom template, replacing the built-in page. | Built-in page |
The subtitle field defaults to
Sign in to the pgEdge Postgres MCP Server.
A custom logo_file must be a PNG, JPEG, GIF or WebP image; the server
refuses any other extension, SVG included, at startup. An SVG can carry
script, and the logo is served from the same origin as the login page,
so it is not an acceptable format here.
The login page links to a favicon, which stops the browser guessing at
/favicon.ico; the authentication middleware rejects that guess, and
the browser reports the rejection in its console. The built-in pgEdge
icon is served unauthenticated from /oauth/static/favicon; a custom
favicon_file must be an ICO or PNG image, refused on the same grounds
as an SVG logo.
In the following example, the login_page block sets a custom title
and colour scheme:
http:
auth:
oauth:
login_page:
title: "Example Corp"
subtitle: "Sign in to your Example Corp database assistant"
message: |
Use your Example Corp single sign-on username and
password.
footer: "(c) Example Corp"
logo_file: "/etc/pgedge/logo.png"
favicon_file: "/etc/pgedge/favicon.ico"
primary_colour: "#123456"
secondary_colour: "#654321"
Custom Templates
Set template_file to replace the login page entirely with a custom
HTML template. See examples/oauth/login.html for an annotated copy of
the built-in template to start from, and
examples/oauth/README.md for a short explanation of the contract.
A custom template must define a template named page, and its form
must post back to the same URL with these hidden fields plus the
CSRF field:
csrf_token, the anti-forgery token supplied in.CSRFToken.response_type,client_id,redirect_uri,scope,state,code_challengeandcode_challenge_method, taken from.OAuth.
The template receives the login_page configuration under .Branding:
.Branding.Title, .Branding.Subtitle, .Branding.MessageParagraphs
(the message field split into paragraphs), .Branding.Footer,
.Branding.LogoURL, .Branding.FaviconURL, .Branding.PrimaryColour
and .Branding.SecondaryColour. A template that ignores these values
renders the same page whatever an operator sets in login_page, so use
them for the title, logo, favicon and colours rather than fixed values.
The template also receives .Error, a message to show when a previous
attempt failed, .Client, the requesting client's name where known,
.ClientRedirectHost, the host and port of the validated redirect URI
(empty on the device page), which a template should show beside the
client name since that name is the client's own unverified claim,
.Scope, the scope a device grant asked for, .UserCode and
.IsDeviceFlow for the device authorisation grant, .Message, the
wording for the final page, and .Page, one of login, device,
done or error, which the template can use to vary its layout for
each stage of the flow. The error variant is rendered when the client
or its redirect URI cannot be trusted, and must not present a
credential form, since there is nowhere safe to send the result. The
device variant's form needs a submit button named action with the
value deny alongside its approve button, so the user can refuse.
Security Notes
The login page never renders the credential itself back to the client;
only the authorisation code and, later, the access and refresh tokens
leave the server. Refresh and access tokens follow the lifetimes
configured under http.auth.oauth, and the token endpoint rate-limits
repeated failed grants per client IP address, the same limiter used
for password login. Serve the issuer over https in any deployment
reachable from outside the machine it runs on; the server refuses a
plain http issuer for any host other than localhost or a loopback
address.
The login page is public by design, since a user arriving from a client has no credential to present yet. Anyone who can reach the server can therefore reach the form and attempt a password. Two consequences are worth planning for:
- Where
max_failed_attempts_before_lockoutis set, a locked account stays locked until an administrator enables it again; there is no automatic unlock after a delay, so an attacker who knows a username can lock that account out deliberately. - The per-IP rate limiter bounds guessing from any one address, but not from many, so put the server behind whatever network controls the deployment warrants rather than relying on the limiter alone.
Disabling, deleting or locking out a user revokes that user's OAuth
tokens at once. Changing a password with -update-user does not; the
existing refresh token stays valid until it expires. To end those
sessions as well, run -disable-user and then -enable-user for the
account after changing the password. Pause for a second or two between
the commands; the server must reload the user file whilst the account
is disabled for the revocation to take effect.
Troubleshooting
If a client cannot discover OAuth, confirm that
/.well-known/oauth-authorization-server returns a JSON document
rather than a 404; a 404 means oauth.issuer is unset, the
oauth method is disabled, or authentication itself is disabled. The
CLI treats any discovery failure the same way in auto mode, not just
a 404: an error status, an unreadable document, one naming another
issuer, or a request that never reaches the server all make it fall
back to the previous authentication. Set auth_mode to oauth to have
it report the failure instead.
If sign-in completes but the client never receives its token, check
that the client's redirect URI appears in
http.auth.oauth.allowed_redirect_uris, or that the web client's
origin appears in http.allowed_origins so the server can derive it
automatically.
If a browser refuses to post the login form, or a request to
/oauth/authorize is rejected before the login page even renders,
check the server's Origin handling; see Browser Origins and DNS
Rebinding.
If the CLI or web client reports the same failure only in headless
environments, use the device authorisation grant: pass --no-browser
to the CLI, or complete the code shown on another device.