Authentication
API keys and the OAuth 2.1 authorization flow, including PKCE, resource binding, token lifetimes, and rotation.
Every request to the public API carries a bearer token:
Authorization: Bearer <credential>That is the only accepted mechanism. Two kinds of credential fit in that header, and the API resolves both to the same internal identity, so every rule about scopes, rate limits, and credits applies identically to each.
Session cookies are never accepted. A request carrying a valid dashboard cookie and no Authorization header gets a 401 from every public endpoint, with no exception. Do not put a credential in a query string either: it would end up in access logs and browser history.
Which one do you need?
| API key | OAuth 2.1 app | |
|---|---|---|
| Who holds it | You | Each of your users |
| Created by | A dashboard admin, by hand | Your app, through a browser consent flow |
| Reaches | Exactly one workspace | The workspaces the user selected at consent |
| Lifetime | Until revoked, or an optional expiry | Access token 60 minutes, refresh token 90 days |
| Right for | Your own scripts, servers, CI, internal dashboards | A product other people sign in to, and MCP clients |
If you are building something for yourself, use an API key and skip to Scopes. If you are building something other people connect their VitalSentinel account to, you need OAuth.
API keys
Creating one
See Quick start for the click-by-click version. The short form: Settings → API access, owner or admin role required, and the key belongs to whichever workspace is active when you create it.
The secret is shown exactly once. VitalSentinel stores only a SHA-256 digest, so a lost key must be rotated, not recovered.
Format
| Environment | Prefix | Length |
|---|---|---|
| Production | vsk_live_ | 43 characters after the prefix |
| Everywhere else | vsk_dev_ | 43 characters after the prefix |
The prefix carries real meaning: a development key physically cannot be pasted into a production client and silently work. The key list in the dashboard shows the first 16 characters of each key so you can tell them apart.
One key, one workspace
A key belongs to a single workspace. To read across several workspaces you need one key per workspace, or an OAuth grant, which can span every workspace the user selected at consent time.
A workspace can hold up to 10 live keys by default. That is sized for one key per integration plus one per CI pipeline. Each live key is an independent rate-limit bucket and an independent thing to leak, which is why the count is bounded even though API access itself is not.
Expiry, IP allowlists, and rotation
| Control | Behavior |
|---|---|
| Expires | Optional. Blank means the key never expires. |
| IP allowlist | Optional list of addresses or CIDR ranges. A request from outside the list is rejected, and an allowlist that cannot be evaluated denies rather than allows. |
| Rotate | Issues a new secret while the old one keeps working through a Grace window you choose: none, 1 hour, 24 hours (recommended), 3 days, or 7 days. A rotation is not a hard cutover across every deployed client at once. |
| Revoke | Immediate. There is no grace window, so use Rotate if you need one. |
last_used_at is recorded at most once a minute, so the key list is a good signal for "is anything still using this" but not a precise audit trail. For that, see the Data Access Log on the workspace.
Every failure looks the same
No such key, revoked, expired, past its rotation grace period, IP outside the allowlist: all of these return the identical 401 body. That is deliberate. Distinguishing them would let someone holding a guessed key learn that it was once real.
OAuth 2.1
The authorization server lives in the VitalSentinel application, not in the MCP server. Any app can use it. The MCP server was the first consumer, but nothing about it is MCP-specific.
The flow is standard OAuth 2.1 authorization code with PKCE, plus RFC 8707 resource indicators. If your framework already speaks OAuth 2.1, the only unusual requirement is that the resource parameter is mandatory.
Discovery
Both documents are unauthenticated and served from the site root, not under the API prefix:
| Document | URL | Specification |
|---|---|---|
| Authorization server metadata | https://app.vitalsentinel.com/.well-known/oauth-authorization-server | RFC 8414 |
| Protected resource metadata | https://app.vitalsentinel.com/.well-known/oauth-protected-resource | RFC 9728 |
A 401 from any endpoint also carries a WWW-Authenticate: Bearer resource_metadata="..." header pointing at the second document. That is the entire discovery mechanism for a client that has never seen VitalSentinel before.
Step 1: register your client
This does not work like Google OAuth. There is no console to visit, no project to create, and no credentials issued to you out of band ahead of time. Your code calls the registration endpoint at runtime and gets a client_id back in the response. Nobody approves it.
That is what makes MCP work: an AI client that has never heard of VitalSentinel discovers this endpoint, registers itself, and connects, with no human in the loop on either side.
Dynamic client registration (RFC 7591) is open by design, so a client can self-register on first use with no manual approval step.
curl -sS -X POST https://app.vitalsentinel.com/api/public/v1/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "Acme SEO Dashboard",
"redirect_uris": ["https://acme.example.com/oauth/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}'The response carries your client_id immediately. Registration is rate limited per source address, at 20 per hour.
Do you get a client secret?
Only if you ask for one. token_endpoint_auth_method decides:
| Value | You are a | Secret | Proof at token exchange |
|---|---|---|---|
none (default) | Public client | None issued | The PKCE code_verifier |
client_secret_basic | Confidential client | Issued once, at registration | The secret, plus PKCE |
client_secret_post | Confidential client | Issued once, at registration | The secret, plus PKCE |
Use none for anything a user runs: a desktop app, a CLI, a browser app, an MCP client. A secret shipped to a user's machine is not a secret, which is what PKCE exists to replace.
Use a confidential client only when the exchange happens on a server you control and the secret never leaves it.
An issued secret is returned once, in the registration response, and is stored hashed. It does not expire. If you lose it, register a new client.
Registration does not deduplicate. Every call creates a new client_id, even with an identical name and redirect URI. Register once and store the result; do not register on every start.
Registered clients start unverified, and an unverified client works exactly like a verified one. The authorization endpoint never consults the flag. It changes only what the consent screen shows: an unverified app is presented as not reviewed by VitalSentinel, with its redirect URI in full. Verification is a trust signal for your users, not an access control on you.
Registration is open and unauthenticated, so your app is usable by any VitalSentinel user the moment you register it. There is no review queue to wait in and no approval to request.
The rules that do bind you:
| Rule | Detail |
|---|---|
| Redirect URIs are matched by exact string equality at authorization time | Never a prefix match, so register every exact URI you will use |
| No wildcards | A * anywhere in a redirect URI is rejected at registration |
https only | Except http on 127.0.0.1, ::1, or localhost, for desktop clients |
| Length | 2048 characters maximum per URI |
| PKCE | Mandatory, S256 only |
Step 2: send the user to authorize
GET https://app.vitalsentinel.com/api/public/v1/oauth/authorize
?response_type=code
&client_id=<your client_id>
&redirect_uri=https://acme.example.com/oauth/callback
&scope=read:overview%20read:domains%20read:gsc
&state=<random, single-use>
&code_challenge=<base64url(sha256(verifier))>
&code_challenge_method=S256
&resource=https://api.vitalsentinel.com| Parameter | Requirement |
|---|---|
response_type | Must be code. |
code_challenge_method | Must be S256. PKCE is mandatory; plain is not accepted. |
resource | Required, exactly one value. See Resource binding below. |
scope | Space-delimited scope strings. See Scopes. |
state | Not optional in practice. Generate it randomly per attempt and check it on the callback. |
The endpoint validates the request and redirects the browser to the VitalSentinel consent screen carrying a signed, ten-minute request handle. It never renders HTML itself.
Failures caused by an unknown client or an unregistered redirect URI are returned to you directly, because redirecting to an unverified URI would itself be the vulnerability. Every other failure is reported by redirecting back to your registered redirect URI with an error parameter.
Step 3: the user consents
The consent screen shows your client name, your redirect URI in full, the scopes you asked for grouped by risk tier (Read, Configure, Run), and a workspace picker. The user chooses which of their workspaces the grant reaches, which is not necessarily all of them.
On approval the browser is redirected to your redirect_uri with code and state.
Step 4: exchange the code
curl -sS -X POST https://app.vitalsentinel.com/api/public/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=authorization_code \
-d code=<the code> \
-d redirect_uri=https://acme.example.com/oauth/callback \
-d client_id=<your client_id> \
-d code_verifier=<the original verifier> \
-d resource=https://api.vitalsentinel.com{
"access_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "vsr_...",
"scope": "read:overview read:domains read:gsc"
}Check the returned scope. It is what was actually granted, which can be narrower than what you asked for.
Authorization codes are single-use and expire after 60 seconds.
Step 5: refresh
curl -sS -X POST https://app.vitalsentinel.com/api/public/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=refresh_token \
-d refresh_token=<current refresh token> \
-d client_id=<your client_id> \
-d resource=https://api.vitalsentinel.comRefresh tokens are single-use and rotate on every call. Every refresh returns a new one and invalidates the one you sent. Presenting a refresh token that has already been rotated away revokes the entire grant, including every access token issued under it, because the only way to hold a superseded refresh token is to have obtained a copy of it.
In practice this means: store the new refresh token before you use the new access token, and never run two refresh attempts concurrently for the same grant. A retry loop that refreshes twice will log your user out.
Token lifetimes
| Token | Lifetime | Shape |
|---|---|---|
| Access token | 60 minutes | JWT, bound to the resource chosen at authorization |
| Refresh token | 90 days | Opaque, stored hashed, single-use with rotation |
| Authorization code | 60 seconds | Single-use |
Revoking
curl -sS -X POST https://app.vitalsentinel.com/api/public/v1/oauth/revoke \
-H "Content-Type: application/x-www-form-urlencoded" \
-d token=<an access or refresh token> \
-d client_id=<your client_id>RFC 7009. Accepts either token type and revokes the whole grant behind it, including access tokens already issued. It always returns 200, including for an unknown token, so it cannot be used to test whether a token exists.
Users can also revoke your app themselves at Settings → Connected apps, which lists their live grants and which workspaces each one reaches. Build for that: a grant can disappear at any time, and your next call gets a 401.
Resource binding is mandatory
The resource parameter (RFC 8707) fixes the audience your token is valid for. It is set at authorization time, copied onto the grant at exchange time, and can never be re-chosen later.
| Target | resource value |
|---|---|
| REST API | https://api.vitalsentinel.com |
| MCP server | https://mcp.vitalsentinel.com |
The two are not symmetric, and it matters when you decide which to ask for:
| Token minted with | REST API | MCP server |
|---|---|---|
resource=https://api.vitalsentinel.com | Accepted | Rejected, 401 with invalid_token |
resource=https://mcp.vitalsentinel.com | Accepted | Accepted |
The MCP server holds no credentials of its own and forwards your token unchanged to the REST API, so the REST API has to accept an MCP-audience token or MCP could not work at all. The check that does bite is the other direction: the MCP server rejects a REST-audience token, so a token harvested from a REST client cannot be replayed into an MCP session.
For an ordinary REST integration, use the REST identifier. There is no privilege difference between the two, only reach.
What the binding still buys, and why it is worth setting correctly: a token minted for this deployment cannot be replayed against a different resource server that trusts the same authorization server. A wrong audience returns 401 with invalid-audience, not a scope error, so it is easy to tell apart from a permissions problem.
OAuth endpoint rate limits
These endpoints run before a credential exists, so they are limited per source IP rather than per credential:
| Endpoint | Limit |
|---|---|
POST /oauth/register | 20 per hour |
GET /oauth/authorize | 120 per hour |
| Consent screen (resolve and submit) | 120 per hour, shared, so a completed consent spends two units |
POST /oauth/token | 3,000 per hour |
POST /oauth/revoke | 600 per hour |
Behind a corporate NAT or a mobile carrier's shared address space, every user shares one bucket. Budget accordingly.
Error format on OAuth endpoints
The token endpoint returns RFC 6749 errors, not the application/problem+json shape the rest of the API uses:
{ "error": "invalid_grant", "error_description": "Authorization code has expired" }Every other endpoint uses the problem format.