Documentation
For app developers
IndieKey is a standard IndieAuth server. Any client that follows the current specification works with it; this page summarizes what that means.
If you only need to know who someone is, the simplest route is indielogin.com, which handles all of the steps below for you.
1. Discover the server
Fetch the URL the user entered (following redirects) and look for rel="indieauth-metadata" in an HTTP Link header or an HTML <link> element. Fetch that URL to get the server metadata:
{
"issuer": "https://indiekey.id/s/SITE_ID/",
"authorization_endpoint": "https://indiekey.id/s/SITE_ID/auth",
"token_endpoint": "https://indiekey.id/token",
"introspection_endpoint": "https://indiekey.id/introspect",
"revocation_endpoint": "https://indiekey.id/revoke",
"userinfo_endpoint": "https://indiekey.id/userinfo",
"code_challenge_methods_supported": ["S256"],
"authorization_response_iss_parameter_supported": true,
...
}
Each website using this server is its own authorization server, with its own metadata URL, issuer and authorization_endpoint. The token, introspection, revocation and userinfo endpoints are shared. Always use the endpoints from the metadata you discovered for the user's website.
The metadata URL is the one RFC 8414 derives from the issuer, https://indiekey.id/.well-known/oauth-authorization-server/s/SITE_ID, so a client that starts from the issuer rather than from the user's home page finds the same document.
2. Publish client metadata
Your client_id is a URL. Serve a JSON document there so the consent screen can show your app's name and logo:
{
"client_id": "https://app.example.com/",
"client_name": "Example App",
"client_uri": "https://app.example.com/",
"logo_uri": "https://app.example.com/logo.png",
"redirect_uris": ["https://app.example.com/callback"]
}
A redirect_uri on a different scheme, host or port from the client_id is only accepted if it is listed in redirect_uris. Apps without a metadata document still work, but the user only sees the bare URL. The older h-app microformat is not read.
3. Send the user to the authorization endpoint
https://indiekey.id/s/SITE_ID/auth?response_type=code
&client_id=https://app.example.com/
&redirect_uri=https://app.example.com/callback
&state=RANDOM_STATE
&code_challenge=BASE64URL(SHA256(VERIFIER))
&code_challenge_method=S256
&scope=profile create
&me=https://user.example.net/
stateand PKCE withS256are required.scopeis optional. Leave it out if you only need to authenticate the user.meis optional. The authorization endpoint already belongs to one website, so that is who the user signs in as.
The user returns to your redirect_uri with code, state and iss. Check that state matches and iss equals the issuer from the metadata.
4. Exchange the code
To get an access token, POST to the token endpoint within 60 seconds:
curl https://indiekey.id/token \
-d grant_type=authorization_code \
-d code=CODE \
-d client_id=https://app.example.com/ \
-d redirect_uri=https://app.example.com/callback \
-d code_verifier=VERIFIER
{
"access_token": "…",
"token_type": "Bearer",
"scope": "profile create",
"expires_in": 3600,
"refresh_token": "…",
"me": "https://user.example.net/",
"profile": { "name": "…", "url": "https://user.example.net/", "photo": "…" }
}
If no scope was granted, no access token is issued and only me is returned. If you only need to know who signed in, POST the same parameters to the authorization endpoint instead, which always returns just me (and profile if granted).
A scope the user's Micropub or Microsub endpoint accepts gives you a JWT that endpoint can verify itself (see verifying access tokens), lasting an hour by default, with a refresh_token to renew it. A sign-in token, with only profile or email, is an opaque string that lasts a week and has no refresh_token: ask the user to sign in again.
5. Verify the returned me
If the returned me differs from what the user entered, fetch it and confirm its metadata has the same issuer as the iss you received before trusting it. Because every website has its own issuer, this check also catches a sign-in for a website whose home page now links somewhere else.
Refreshing and revoking
curl https://indiekey.id/token \
-d grant_type=refresh_token \
-d refresh_token=REFRESH_TOKEN \
-d client_id=https://app.example.com/
Refresh tokens rotate: each use returns a new one, and presenting an old one again revokes the grant. They expire after 90 days without use.
To sign out, POST token=… to https://indiekey.id/revoke. This revokes the access token and every token issued with it.
Userinfo
With a token that has the profile or email scope, GET https://indiekey.id/userinfo with an Authorization: Bearer header to fetch the current profile.
Naming the resource a token is for
Once you have discovered the user's Micropub endpoint, you can ask for a token bound to it with the resource parameter of RFC 8707, on the authorization request and again at the token endpoint. The value is the endpoint's URL, or the URL of the user's website; it may be given more than once. Anything the user's website neither is nor links to as an endpoint is refused with invalid_target.
GET https://indiekey.id/s/SITE_ID/auth?response_type=code&…
&scope=create
&resource=https%3A%2F%2Fuser.example.net%2Fmicropub
The consent screen names the resource, the access token's aud is the resources named rather than every endpoint the scopes cover, and a resource server introspecting the token is answered only if it is one of them. At the token endpoint, and on a refresh, resource may narrow the list but not add to it; left out, the token is for all of them.
Resource servers: introspecting with your own keys
A Micropub or media endpoint can ask https://indiekey.id/introspect whether a token is active without any credential being set up here. It proves who it is with a key pair it publishes itself, and IndieKey finds the key from the resource's identifier alone. This is a small profile of three standards, and this server is its first implementation:
- RFC 9728 (protected resource metadata) is where the keys are published.
- RFC 7523 §2.2 (
private_key_jwt) is how the request is signed. - The user's website, through
rel="micropub"andrel="microsub"links, is what lets a resource see the user's tokens — the same way it already names the user's authorization server.
The server advertises the method with "introspection_endpoint_auth_methods_supported": ["Bearer", "private_key_jwt"]. The Bearer credential described under verifying access tokens keeps working.
Choose a resource identifier
The identifier is an https URL with no query or fragment, on a domain name, and it decides whose tokens you may ask about:
- Self-hosted: an endpoint on the user's own website uses the website itself,
https://user.example.net/, or any URL under it, such ashttps://user.example.net/micropub. On a shared host,https://example.com/~alice/covershttps://example.com/~alice/micropuband nothing of/~bob/'s. - Hosted: a service that runs endpoints for many websites uses one identifier per website it hosts, such as
https://service.example/u/alice/, where the user's home page links tohttps://service.example/u/alice/micropubwithrel="micropub". The identifier must cover that endpoint: be it, or a path above it on the same origin.
Publish protected resource metadata
Serve a JSON document at the identifier's well-known URL: /.well-known/oauth-protected-resource inserted between the host and the path, with a trailing slash dropped from the path. For https://user.example.net/ that is https://user.example.net/.well-known/oauth-protected-resource; for https://service.example/u/alice/ it is https://service.example/.well-known/oauth-protected-resource/u/alice.
{
"resource": "https://user.example.net/",
"resource_name": "My website",
"authorization_servers": ["https://indiekey.id/s/SITE_ID/"],
"jwks_uri": "https://user.example.net/jwks.json",
"scopes_supported": ["create", "update", "delete", "media"],
"bearer_methods_supported": ["header", "body"]
}
resourcemust be exactly the identifier, character for character.authorization_servers, if present, must include the user'sissuerfrom the metadata you discovered. A hosted service lists the server of the website each identifier is for.jwks_uriis an https URL serving your public keys as a JWK Set. It can be shared across every identifier a service uses. Each key needs akid; RS256 (RSA, 2048 bits or more) and ES256 (P-256) are accepted.
{"keys": [{"kty": "RSA", "n": "…", "e": "AQAB", "alg": "RS256", "use": "sig", "kid": "…"}]}
Sign the introspection request
Send the token with a client assertion instead of an Authorization header. The assertion is a JWT signed with your private key, with the kid of the matching published key in its header.
POST https://indiekey.id/introspect
Content-Type: application/x-www-form-urlencoded
Accept: application/json
token=ACCESS_TOKEN
&client_id=https%3A%2F%2Fuser.example.net%2F
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=JWT
{
"iss": "https://user.example.net/",
"sub": "https://user.example.net/",
"aud": "https://indiekey.id/s/SITE_ID/",
"iat": 1793123202,
"exp": 1793123262,
"jti": "489bbe4bebc9ee305a19d783d1de2033"
}
iss,suband the optionalclient_idparameter are all the resource identifier.audis theissuerof the website whose token this is, from the metadata you discovered for it — not the introspection endpoint's URL. A hosted service uses the server of the website the request is for. A list is accepted.expis at most five minutes afteriat; a minute of clock skew is allowed on both.jtiis unique: each assertion is accepted once.
A request that fails these checks gets HTTP 401 with {"error": "invalid_client", "error_description": "…"}, and no active field. Metadata and keys are cached for five minutes to an hour, honouring Cache-Control within that; when a signature does not verify against the cached keys, or the kid is unknown, both documents are read once more before the request is refused, so publish a new key before signing with it. Documents are fetched over https only, from public addresses, without following redirects, must be JSON, and may be at most 64 KB.
Which tokens you are told about
A good assertion says who is asking, not what it may know. The token is then looked up, and the full response is returned only if the resource is allowed to see it; anything else is {"active": false}, the same as for an unknown or revoked token, so that no answer reveals a token exists. In order:
- A token issued with a
resourceindicator is for those resources alone: the identifier must be one of them, and nothing else is consulted. - Otherwise the token's website (its
me) must cover the identifier — same scheme, host and port, and the identifier's path equal to the website's or under it on a segment boundary. - Or the identifier must cover an endpoint the website links to with
rel="micropub"orrel="microsub", as an HTML<link>in its<head>or an HTTPLinkheader, relative URLs resolved against the website. No other link counts;rel="authorization_endpoint",rel="token_endpoint"andrel="indieauth-metadata"name who issues tokens, not who may read them.
me | links rel="micropub" to | resource identifier | Allowed? |
|---|---|---|---|
https://user.example.net/ | (any) | https://user.example.net/ | yes |
https://example.com/~alice/ | (any) | https://example.com/~alice/micropub | yes |
https://example.com/~alice/ | (any) | https://example.com/~bob/ | no |
https://example.com/ | https://service.example/u/alice/micropub | https://service.example/u/alice/ | yes |
https://example.com/ | https://service.example/u/alice/micropub | https://service.example/u/bob/ | no |
https://example.com/ | nothing on service.example | https://service.example/u/alice/ | no |
The links a website is known to have are read when it is verified and used for an hour; after that, and whenever they fail to name the resource asking, the home page is read again (at most every five minutes) before the answer is given, so a website that has just moved its endpoint is not turned away. This rule does not replace your own checks: a hosted endpoint still confirms the token's me is the website the request is for.