Appearance
OAuth and OpenID Connect reference
Your sign in service is a standard OAuth 2.1 authorization server and OpenID Connect provider. Any OpenID Connect library can sign people in with it. The FastAPI SDK does all of this for you, so read on when you use another language, a gateway, or want to know exactly what happens.
The issuer is your sign in address, such as https://acme.microauth.dev, or your custom domain. Every endpoint below is on that host.
Discovery
Both of these return the same document:
text
https://acme.microauth.dev/.well-known/openid-configuration
https://acme.microauth.dev/.well-known/oauth-authorization-serverjson
{
"issuer": "https://acme.microauth.dev",
"authorization_endpoint": "https://acme.microauth.dev/oauth/authorize",
"token_endpoint": "https://acme.microauth.dev/oauth/token",
"userinfo_endpoint": "https://acme.microauth.dev/oauth/userinfo",
"jwks_uri": "https://acme.microauth.dev/oauth/jwks",
"revocation_endpoint": "https://acme.microauth.dev/oauth/revoke",
"introspection_endpoint": "https://acme.microauth.dev/oauth/introspect",
"end_session_endpoint": "https://acme.microauth.dev/oauth/logout",
"pushed_authorization_request_endpoint": "https://acme.microauth.dev/oauth/par",
"scopes_supported": ["openid", "profile", "email", "offline_access", "roles", "posts:read", "posts:write"],
"response_types_supported": ["code"],
"response_modes_supported": ["query", "form_post"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"],
"introspection_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"prompt_values_supported": ["none", "login", "consent", "create"],
"authorization_response_iss_parameter_supported": true
}That's shortened: the real document also lists the other endpoints' auth methods and the supported claims. scopes_supported lists the standard scopes, then your permissions. It leaves out the reserved microauth:api_keys scope. When apps people connect are on and your plan includes them, the document also has client_id_metadata_document_supported: true and a registration_endpoint.
The document is served with Cache-Control: public, max-age=3600, so a change to your settings can take up to an hour to reach clients that cached it.
Discovery, JWKS, token, userinfo, revocation and registration answer cross origin requests without credentials, so browser apps can call them.
Endpoints
| Endpoint | What it does |
|---|---|
/oauth/authorize | Starts sign in and consent |
/oauth/par | Pushed authorization requests (RFC 9126) |
/oauth/token | Exchanges a code or a refresh token for tokens |
/oauth/userinfo | Claims about the signed in person |
/oauth/jwks | Public signing keys |
/oauth/revoke | Revokes a refresh token (RFC 7009) |
/oauth/introspect | Checks a token or API key (RFC 7662) |
/oauth/logout | Signs out (OpenID Connect RP-Initiated Logout) |
/oauth/register | Dynamic client registration (RFC 7591), when enabled |
Authorization request
Send the browser to /oauth/authorize with these parameters:
| Parameter | Required | Notes |
|---|---|---|
response_type | Yes | Always code |
client_id | Yes | The application's client ID, or a metadata document URL |
redirect_uri | Yes | Must match a registered URI exactly |
code_challenge | Yes | The S256 PKCE challenge. Every client needs it |
code_challenge_method | Yes | S256. plain isn't accepted |
scope | Yes | Space separated. Include openid to get an ID token |
state | Recommended | Returned unchanged. Check it to stop cross site request forgery |
nonce | Recommended | Copied into the ID token |
resource | For permissions | The API or MCP server the access token is for (RFC 8707) |
response_mode | No | query (the default) or form_post |
prompt | No | none, login, consent or create. create opens the sign up screen |
max_age | No | Seconds since the last sign in before the person must sign in again |
login_hint | No | An email address to fill in |
When it works, the browser comes back with code, state and iss. Check that iss is your issuer (RFC 9207) before you use the code. Errors come back the same way, with error and error_description.
A request with an unknown client_id or a redirect_uri that doesn't match shows an error page. It never redirects.
Someone already signed in
When the person is already signed in to your sign in pages:
- With
prompt=create, they see the sign up screen with a choice: continue as the account they're signed in with, or sign out and create another one. While sign ups are closed, they simply continue. - With a
login_hintnaming a different email, they choose between continuing as the signed in account and signing in as the one you named. - With
prompt=noneas well, either case answerslogin_required, because only the person can choose.
Continuing goes on with the same request without signing in again, unless prompt=login or max_age asks for a fresh sign in.
Pushed authorization requests
Instead of putting the parameters in the URL, a client can post them, with its client authentication, to /oauth/par. It gets a request_uri back and sends the browser to /oauth/authorize?client_id=...&request_uri=.... That keeps the request out of browser history and logs. PAR is supported, not required.
Token endpoint
POST /oauth/token with a form encoded body. Web apps authenticate with their secret, using HTTP Basic (client_secret_basic) or form fields (client_secret_post). Single page and native apps send their client_id and no secret.
Exchange a code:
bash
curl https://acme.microauth.dev/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri=https://app.example.com/auth/callback \
-d code_verifier="$VERIFIER"Refresh:
bash
curl https://acme.microauth.dev/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH_TOKEN"json
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
"token_type": "Bearer",
"expires_in": 600,
"refresh_token": "...",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
"scope": "openid profile email roles"
}Codes last 60 seconds and work once. expires_in is the service's access token lifetime in seconds, and never more than 600 for apps people connect. Refresh tokens change on every use: store the new one each time. See refresh tokens for the rotation rules.
Scopes
| Scope | Adds |
|---|---|
openid | An ID token. Required for OpenID Connect |
profile | name, picture and updated_at |
email | email and email_verified |
roles | roles and permissions, both always present when granted. Only for your own applications |
offline_access | A refresh token tied to the grant instead of the sign in session. Only for apps allowed to use refresh tokens |
| Your permissions | Scopes in the access token for the server named in resource |
phone and address aren't supported and are dropped. An unknown scope, or a permission without resource, fails with invalid_scope. Permissions the person doesn't hold are left out without an error. How scopes are granted has every rule.
Access tokens
Access tokens are JWTs (RFC 9068) signed with RS256. The header has "typ": "at+jwt" and the kid of the signing key.
json
{
"iss": "https://acme.microauth.dev",
"sub": "0199a1b4-6c2e-7d1f-9a3b-5e8c2f4d6a10",
"aud": "https://api.example.com",
"client_id": "0199b2c5-...",
"scope": "posts:read posts:write",
"exp": 1760400600,
"iat": 1760400000,
"auth_time": 1760399000,
"jti": "Q3VYH6ZK5LM2NB7RTW4XAP2CDE",
"sid": "0199c3d6-..."
}aud is the server named in resource. When openid was granted it's a list that also holds the issuer, so the token works at /oauth/userinfo.
To check an access token in your API:
- Read the header.
algmust beRS256andtypmust beat+jwt. - Find the key with that
kidin/oauth/jwks. Cache the keys, and fetch them again when a token names one you haven't seen. - Verify the signature.
issmust be your issuer exactly.audmust contain your server's URL exactly as registered.expmust be in the future. Allow a little clock skew.scopemust hold what the route needs.
When a signing key is rotated, the old one stays in JWKS for your access token lifetime plus 24 hours, so tokens it signed keep working until they expire.
ID tokens
ID tokens are for the app that signed the person in. Don't send them to APIs. They're RS256 JWTs with:
| Claim | Meaning |
|---|---|
iss, sub, aud, exp, iat | The standard claims. aud is the client ID |
auth_time | When the person last signed in |
amr | How they signed in, such as with a password or a passkey |
nonce | The nonce from the authorization request |
sid | The sign in session |
at_hash | Ties the ID token to the access token issued with it |
Plus the claims the granted scopes add. sub is the person's ID on this sign in service and never changes.
Userinfo
GET /oauth/userinfo with Authorization: Bearer <access token> returns the same claims as the ID token for the granted scopes:
json
{
"sub": "0199a1b4-6c2e-7d1f-9a3b-5e8c2f4d6a10",
"name": "Ada Lovelace",
"picture": "https://static.microauth.com/avatars/...",
"email": "ada@example.com",
"email_verified": true,
"roles": ["editor"],
"permissions": ["posts:read", "posts:write"]
}Revocation
POST /oauth/revoke with token and an optional token_type_hint, and the same client authentication as the token endpoint. Revoking a refresh token ends that chain. An app can only revoke its own refresh tokens. Access tokens can't be revoked: they're JWTs that end on their own, so sending one changes nothing. The answer is 200 even for a token that's unknown or already revoked, as RFC 7009 asks.
Introspection
POST /oauth/introspect tells you whether a token is active right now. A token is inactive once its session has ended, the person is suspended or the grant is revoked, even before it expires.
- Your web applications authenticate with their client secret and get an active answer only for their own tokens, matched by
client_id. Single page and native apps have no secret, so they can't introspect. - APIs and MCP servers authenticate with their server credentials, whose client ID starts with
rs_. An access token is active only when itsaudcontains that server. They can also check API keys.
Requests whose server credentials check out, client ID and secret both, count only against those credentials' own limit: 100 a second, in bursts of up to 1000. Over it, the answer is 429 with temporarily_unavailable and Retry-After: 1. Other requests, including ones with a wrong secret, share a limit per network of 50 a second, in bursts of up to 500.
An active access token answer:
json
{
"active": true,
"token_use": "access_token",
"client_id": "0199b2c5-...",
"sub": "0199a1b4-6c2e-7d1f-9a3b-5e8c2f4d6a10",
"scope": "posts:read posts:write",
"aud": "https://api.example.com",
"iss": "https://acme.microauth.dev",
"iat": 1760400000,
"exp": 1760400600
}When a web app introspects its own refresh token, token_use is "refresh_token" and aud is left out. Everything else, such as an expired, revoked or unknown token, or someone else's, gets exactly {"active": false}. Every 200 answer carries Cache-Control: max-age=30.
Logout
Send the browser to /oauth/logout with:
| Parameter | Notes |
|---|---|
id_token_hint | The ID token from sign in |
post_logout_redirect_uri | One of the app's sign out URLs |
client_id | The application's client ID |
state | Returned on the redirect |
With a valid id_token_hint and a registered post_logout_redirect_uri, the person is signed out right away and sent back. Otherwise they confirm on a MicroAuth page first.
Apps people connect
These apply when apps people connect are on. Their access tokens last at most 10 minutes, even when the service's access token lifetime is longer, so after a person disconnects one, or you block it, the access token it already has works for 10 minutes at most.
Client ID Metadata Documents
An app can use the URL of a JSON document it hosts as its client_id. This is how MCP clients identify themselves. MicroAuth fetches the document when the app first asks for access.
json
{
"client_id": "https://client.example.com/oauth/client.json",
"client_name": "Example Assistant",
"redirect_uris": ["https://client.example.com/callback", "http://127.0.0.1/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"]
}- The URL must be https with a path, such as
https://client.example.com/oauth/client.json, with no query, fragment, user name or.and..segments, and up to 2000 characters. client_idinside the document must equal the URL exactly.client_nameandredirect_urisare required.- These apps have no secret:
token_endpoint_auth_methodisnoneor left out. A document with aclient_secret, or one that asks forclient_secret_basic,client_secret_postorprivate_key_jwt, is refused. grant_typesmust includeauthorization_codeand may addrefresh_token. The app only gets refresh tokens when it does.- Redirect URIs follow the same rules as for registered apps: a loopback or custom scheme redirect makes the app native, and its loopback redirects match on any port.
- The document can be up to 5 KB, with up to 20 redirect URIs. Redirects aren't followed, and only public addresses are fetched.
- Documents are cached as their HTTP cache headers say, between 5 minutes and 24 hours, and for an hour when they don't say.
no-storeandno-cachemean MicroAuth fetches them every time. Errors are never cached. - When a document can't be fetched or isn't valid, the person sees an error page, and the token endpoint answers
invalid_client.
Dynamic client registration
Older clients can register with POST /oauth/register (RFC 7591):
bash
curl https://acme.microauth.dev/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "Example CLI",
"application_type": "native",
"redirect_uris": ["http://127.0.0.1/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"]
}'The answer is 201:
json
{
"client_id": "0199d4e7-8a1b-7c2d-9e3f-4a5b6c7d8e9f",
"client_id_issued_at": 1791900000,
"application_type": "native",
"client_name": "Example CLI",
"redirect_uris": ["http://127.0.0.1/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"]
}- Send the request as
application/json, up to 16 KB. client_nameis optional, up to 100 characters. An app without one shows as "Unnamed app".application_typeis optional. Without it, the app counts asnativewhen any of its redirect URIs is a loopback address or uses a custom scheme, and aswebotherwise. An explicitwebonly allows https redirects on a real host.- Native apps may use loopback redirects on
localhost,127.0.0.1or[::1], which match on any port, and custom schemes such ascom.example.app:/callbackorcursor://.... Reserved schemes such asjavascript,data,fileandmailtoare refused. Plainhttponly works on loopback addresses. - One request can list several kinds of redirect URI. Cursor, for example, registers a custom scheme, an https URL and a
localhostURL together. token_endpoint_auth_methodmay benone,client_secret_basicorclient_secret_post, but no secret is ever issued: the app is registered as public and the answer always saysnone.private_key_jwtis refused.grant_typesdefaults to["authorization_code"]. Addrefresh_tokento get refresh tokens.- Each network can register 20 apps an hour, and each service takes 200 an hour. Past that, the answer is
429withtemporarily_unavailableandRetry-After: 600. While apps people connect are off, registration answers403withaccess_denied. - A service holds up to 1000 registered apps. A new one replaces the app that has gone longest without use and that nobody connected. When none can go, registration answers
403. - Apps people connect, registered or known by a metadata document, are removed after 30 days without use, unless a person still has a working refresh token for one. Blocked apps are kept.
- The consent screen labels every app people connect unverified, because anyone can register one or publish a document.
Errors
| Error | Usual cause |
|---|---|
invalid_request | A required parameter is missing or repeated, or PKCE is missing |
invalid_client | Wrong client ID or secret |
invalid_grant | The code or refresh token is expired, already used or revoked, or code_verifier doesn't match |
unsupported_grant_type | grant_type is something other than authorization_code or refresh_token |
invalid_scope | An unknown scope, or a permission without resource or not exposed by that server |
invalid_target | resource isn't a server registered on this service, more than one resource was sent, or the token request names a different resource than the sign in did |
unauthorized_client | Refresh tokens are turned off for the app, or you blocked it |
access_denied | The person declined to give access, their account is suspended, or an app asked for consent too many times |
login_required, consent_required | prompt=none was sent but the person has to sign in or consent |
temporarily_unavailable | The service isn't accepting sign ins right now, or too many requests came from your network. Wait and try again |
Check tokens and keys from any language
Your APIs and MCP servers can check API keys, and access tokens, with one HTTP call to the standard introspection endpoint (RFC 7662). OAuth libraries and API gateways in most languages support it, so you don't need a MicroAuth SDK. This is the same contract the FastAPI SDK uses.
The key format
text
<prefix>_<40 random characters><6 character checksum>- The prefix is the one you chose for the service: 2 to 10 lowercase letters or digits, starting with a letter.
- The 40 random characters and the checksum use
0-9,A-Zanda-z. - The checksum is the CRC-32 of everything before it (the prefix, the underscore and the 40 characters), written in base 62 with the alphabet
0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz, most significant digit first, padded with0to 6 characters.
Check the format and checksum first, and refuse a value that fails without calling MicroAuth:
python
import re
import zlib
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
KEY_FORMAT = re.compile(r"[a-z][a-z0-9]{1,9}_[0-9A-Za-z]{46}")
def checksum(body: str) -> str:
n = zlib.crc32(body.encode("ascii"))
digits = ""
while n:
n, r = divmod(n, 62)
digits = ALPHABET[r] + digits
return digits.rjust(6, "0")
def looks_like_key(key: str) -> bool:
return KEY_FORMAT.fullmatch(key) is not None and key[-6:] == checksum(key[:-6])js
import { crc32 } from 'node:zlib' // Node 20.15 or 22.2 and later
const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
const KEY_FORMAT = /^[a-z][a-z0-9]{1,9}_[0-9A-Za-z]{46}$/
function checksum(body) {
let n = crc32(body)
let digits = ''
while (n > 0) {
digits = ALPHABET[n % 62] + digits
n = Math.floor(n / 62)
}
return digits.padStart(6, '0')
}
export function looksLikeKey(key) {
return KEY_FORMAT.test(key) && key.slice(-6) === checksum(key.slice(0, -6))
}Use this key to test your code. Its CRC-32 is 0x31009039, so its checksum is 0tdX69:
text
cms_8Kq2ZtR1vX9mB4nC7dF0gH3jL6pS5wY2aE8uT1oQ0tdX69A value with dots in it is a JWT access token, not a key.
Ask MicroAuth
POST {issuer}/oauth/introspect with the key as token, authenticated with the calling server's server credentials over HTTP Basic. Form encode the client ID and secret before base64, as RFC 6749 section 2.3.1 says. Send Accept: application/json:
bash
curl https://acme.microauth.dev/oauth/introspect \
-u "$RESOURCE_CLIENT_ID:$RESOURCE_SECRET" \
-H "Accept: application/json" \
-d token=cms_8Kq2ZtR1vX9mB4nC7dF0gH3jL6pS5wY2aE8uT1oQ0tdX69The key travels only over TLS, as RFC 7662 requires. MicroAuth hashes it on arrival and never logs it. You can also send client_id and client_secret as form fields instead of HTTP Basic. Wrong server credentials get 401 with invalid_client, and a request without token gets 400 with invalid_request.
The answer
An active key that works at two servers, with an expiry and a per person limit:
json
{
"active": true,
"token_use": "api_key",
"key_id": "5f0c9a7e-3b2d-4c1e-9a8f-6d4b2e1c0a97",
"sub": "8d2f6b1a-4c3e-4f5a-9b7d-1e0c2a4f6d83",
"scope": "posts:read posts:write",
"aud": [
"https://api.example.com",
"https://mcp.example.com/mcp"
],
"iss": "https://acme.microauth.dev",
"iat": 1791900000,
"exp": 1799676000,
"requests_per_minute": 120
}A key for one server that never expires, at a server with no per person limit:
json
{
"active": true,
"token_use": "api_key",
"key_id": "e7a3d9c1-2b4f-4e6a-9c8d-0f1b3a5c7e92",
"sub": "8d2f6b1a-4c3e-4f5a-9b7d-1e0c2a4f6d83",
"scope": "posts:read",
"aud": "https://api.example.com",
"iss": "https://acme.microauth.dev",
"iat": 1791800000
}| Field | Meaning |
|---|---|
token_use | api_key for keys and access_token for access tokens. Use it if a route should accept only one kind of credential |
key_id | The key's ID, the same in the dashboard and in activity events |
sub | The person the key belongs to |
scope | The permissions it has at your server right now |
aud | A string when the key works at one server. Otherwise an array of every server the key names, so check that yours is in it |
iat | When the key was created |
exp | When the key expires. Left out when it never does |
requests_per_minute | The limit you set for each person's keys at this server. Left out when you set none |
The fields always come in this order. Anything else, whether unknown, revoked, expired, suspended or meant for another server, is exactly:
json
{ "active": false }A key only shows as active to the servers it names, so another server's credentials can't tell whether it exists. The scope is worked out at every check from what the key lists, what your server exposes and what the person still holds.
An access token sent with your server credentials comes back active only when its aud contains your server, with the fields shown under introspection.
Caching and outages
- Every
200answer carriesCache-Control: max-age=30. Cache it, active or not, for that long. - Cache by the SHA-256 of the key, never the key itself.
- Never cache errors, such as a
401for wrong server credentials, a429or a5xx. - When MicroAuth can't be reached and you have no cached answer, answer your caller
503. Don't let the request through.
With 30 second caching, a revoked key or a removed role stops working within 30 seconds of the change.
Answering your callers
Answer the way you would for a bad access token (RFC 6750):
| Situation | Status | Header |
|---|---|---|
No credential, a bad checksum or active: false | 401 | WWW-Authenticate: Bearer error="invalid_token" |
| Active, but missing a permission the route needs | 403 | WWW-Authenticate: Bearer error="insufficient_scope", scope="posts:write" |
Over requests_per_minute for that sub | 429 | Retry-After |
| MicroAuth unreachable, nothing cached | 503 |
Count requests_per_minute per person (sub) rather than per key, so making more keys doesn't raise the limit. Introspection itself allows each set of server credentials 100 requests a second, in bursts of up to 1000, and answers 429 with Retry-After: 1 past that. Throttle callers who keep sending unknown keys instead of passing each one on.