Skip to content

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-server
json
{
  "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 ​

EndpointWhat it does
/oauth/authorizeStarts sign in and consent
/oauth/parPushed authorization requests (RFC 9126)
/oauth/tokenExchanges a code or a refresh token for tokens
/oauth/userinfoClaims about the signed in person
/oauth/jwksPublic signing keys
/oauth/revokeRevokes a refresh token (RFC 7009)
/oauth/introspectChecks a token or API key (RFC 7662)
/oauth/logoutSigns out (OpenID Connect RP-Initiated Logout)
/oauth/registerDynamic client registration (RFC 7591), when enabled

Authorization request ​

Send the browser to /oauth/authorize with these parameters:

ParameterRequiredNotes
response_typeYesAlways code
client_idYesThe application's client ID, or a metadata document URL
redirect_uriYesMust match a registered URI exactly
code_challengeYesThe S256 PKCE challenge. Every client needs it
code_challenge_methodYesS256. plain isn't accepted
scopeYesSpace separated. Include openid to get an ID token
stateRecommendedReturned unchanged. Check it to stop cross site request forgery
nonceRecommendedCopied into the ID token
resourceFor permissionsThe API or MCP server the access token is for (RFC 8707)
response_modeNoquery (the default) or form_post
promptNonone, login, consent or create. create opens the sign up screen
max_ageNoSeconds since the last sign in before the person must sign in again
login_hintNoAn 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_hint naming a different email, they choose between continuing as the signed in account and signing in as the one you named.
  • With prompt=none as well, either case answers login_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 ​

ScopeAdds
openidAn ID token. Required for OpenID Connect
profilename, picture and updated_at
emailemail and email_verified
rolesroles and permissions, both always present when granted. Only for your own applications
offline_accessA refresh token tied to the grant instead of the sign in session. Only for apps allowed to use refresh tokens
Your permissionsScopes 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:

  1. Read the header. alg must be RS256 and typ must be at+jwt.
  2. Find the key with that kid in /oauth/jwks. Cache the keys, and fetch them again when a token names one you haven't seen.
  3. Verify the signature.
  4. iss must be your issuer exactly.
  5. aud must contain your server's URL exactly as registered.
  6. exp must be in the future. Allow a little clock skew.
  7. scope must 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:

ClaimMeaning
iss, sub, aud, exp, iatThe standard claims. aud is the client ID
auth_timeWhen the person last signed in
amrHow they signed in, such as with a password or a passkey
nonceThe nonce from the authorization request
sidThe sign in session
at_hashTies 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 its aud contains 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:

ParameterNotes
id_token_hintThe ID token from sign in
post_logout_redirect_uriOne of the app's sign out URLs
client_idThe application's client ID
stateReturned 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_id inside the document must equal the URL exactly.
  • client_name and redirect_uris are required.
  • These apps have no secret: token_endpoint_auth_method is none or left out. A document with a client_secret, or one that asks for client_secret_basic, client_secret_post or private_key_jwt, is refused.
  • grant_types must include authorization_code and may add refresh_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-store and no-cache mean 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_name is optional, up to 100 characters. An app without one shows as "Unnamed app".
  • application_type is optional. Without it, the app counts as native when any of its redirect URIs is a loopback address or uses a custom scheme, and as web otherwise. An explicit web only allows https redirects on a real host.
  • Native apps may use loopback redirects on localhost, 127.0.0.1 or [::1], which match on any port, and custom schemes such as com.example.app:/callback or cursor://.... Reserved schemes such as javascript, data, file and mailto are refused. Plain http only 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 localhost URL together.
  • token_endpoint_auth_method may be none, client_secret_basic or client_secret_post, but no secret is ever issued: the app is registered as public and the answer always says none. private_key_jwt is refused.
  • grant_types defaults to ["authorization_code"]. Add refresh_token to get refresh tokens.
  • Each network can register 20 apps an hour, and each service takes 200 an hour. Past that, the answer is 429 with temporarily_unavailable and Retry-After: 600. While apps people connect are off, registration answers 403 with access_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 ​

ErrorUsual cause
invalid_requestA required parameter is missing or repeated, or PKCE is missing
invalid_clientWrong client ID or secret
invalid_grantThe code or refresh token is expired, already used or revoked, or code_verifier doesn't match
unsupported_grant_typegrant_type is something other than authorization_code or refresh_token
invalid_scopeAn unknown scope, or a permission without resource or not exposed by that server
invalid_targetresource 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_clientRefresh tokens are turned off for the app, or you blocked it
access_deniedThe person declined to give access, their account is suspended, or an app asked for consent too many times
login_required, consent_requiredprompt=none was sent but the person has to sign in or consent
temporarily_unavailableThe 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-Z and a-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 with 0 to 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_8Kq2ZtR1vX9mB4nC7dF0gH3jL6pS5wY2aE8uT1oQ0tdX69

A 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_8Kq2ZtR1vX9mB4nC7dF0gH3jL6pS5wY2aE8uT1oQ0tdX69

The 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
}
FieldMeaning
token_useapi_key for keys and access_token for access tokens. Use it if a route should accept only one kind of credential
key_idThe key's ID, the same in the dashboard and in activity events
subThe person the key belongs to
scopeThe permissions it has at your server right now
audA string when the key works at one server. Otherwise an array of every server the key names, so check that yours is in it
iatWhen the key was created
expWhen the key expires. Left out when it never does
requests_per_minuteThe 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 200 answer carries Cache-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 401 for wrong server credentials, a 429 or a 5xx.
  • 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):

SituationStatusHeader
No credential, a bad checksum or active: false401WWW-Authenticate: Bearer error="invalid_token"
Active, but missing a permission the route needs403WWW-Authenticate: Bearer error="insufficient_scope", scope="posts:write"
Over requests_per_minute for that sub429Retry-After
MicroAuth unreachable, nothing cached503

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.

MicroAuth is a product of Zyref, LLC.