IndieAuth Implementation¶
Django-IndieWeb provides a complete IndieAuth implementation that supports both authentication (logging into sites) and authorization (granting permissions to apps).
Overview¶
IndieAuth is a federated login protocol that enables users to sign in to websites using their own domain name. Django-IndieWeb implements all three IndieAuth endpoints:
Authorization Endpoint (
/indieweb/auth/) - Handles user consent and generates auth codesToken Endpoint (
/indieweb/token/) - Exchanges auth codes for access tokensAuthentication - Verifies auth codes for login-only flows
Customizing the Consent Screen¶
The consent screen template can be customized by overriding indieweb/consent.html in your project:
Create the directory structure in your templates folder:
templates/ └── indieweb/ └── consent.htmlCopy the default template as a starting point:
{% extends "base.html" %} {% block content %} <div class="authorization-request"> <h1>Authorization Request</h1> <p><strong>{{ client_id }}</strong> is requesting access to your site.</p> {% if scope_list %} <p>Requested permissions:</p> <ul> {% for permission in scope_list %} <li>{{ permission }}</li> {% endfor %} </ul> {% endif %} <form method="post"> {% csrf_token %} <input type="hidden" name="client_id" value="{{ client_id }}"> <input type="hidden" name="redirect_uri" value="{{ redirect_uri }}"> <input type="hidden" name="state" value="{{ state }}"> <input type="hidden" name="me" value="{{ me }}"> {% if scope %} <input type="hidden" name="scope" value="{{ scope }}"> {% endif %} {% if code_challenge %} <input type="hidden" name="code_challenge" value="{{ code_challenge }}"> <input type="hidden" name="code_challenge_method" value="{{ code_challenge_method }}"> {% endif %} <button type="submit" name="action" value="approve">Approve</button> <button type="submit" name="action" value="deny">Deny</button> </form> </div> {% endblock %}
Available template context variables:
client_id- The application requesting accessredirect_uri- Where to redirect after authorizationstate- State parameter for CSRF protectionme- The user’s identity URLscope- Normalized space-separated list of requested scopes, orNonewhen no scope was requestedscope_list- Python list of individual scopescode_challenge- PKCE challenge from the authorization request, orNonewhen no PKCE was sent. Custom templates that omit this hidden input will silently drop PKCE and will not interoperate with PKCE clients.code_challenge_method- PKCE method (S256orplain) paired withcode_challenge
Security Considerations¶
HTTPS Required: Always use HTTPS in production for all IndieAuth endpoints
Auth Code Timeout: Auth codes expire after 60 seconds by default
Token Expiration: Access tokens expire after 24 hours by default; the Micropub endpoint rejects expired tokens with HTTP 401
CSRF Protection: The consent form includes Django’s CSRF token
User Authentication: Users must be logged in to approve/deny requests
PKCE (RFC 7636): The authorization endpoint accepts an optional
code_challenge(43-128 characters from the unreserved set[A-Za-z0-9._~-]) andcode_challenge_method(S256orplain; defaults toplainwhencode_challengeis sent without a method, per RFC 7636 §4.3). Malformed challenges and unsupported methods are rejected with HTTP 400 before any authorization code is issued. When an auth code was issued with acode_challenge, the token endpoint requires a matchingcode_verifierand rejects any mismatch, any missing verifier, any verifier submitted without a stored challenge, and any verifier outside the RFC length and character set withinvalid_grant. The S256 verification computesBASE64URL-WITHOUT-PADDING(SHA256(verifier))and compares it to the stored challenge in constant time. Auth codes issued before this change (nocode_challengestored) continue to be redeemable without acode_verifier, preserving backwards compatibility for legacy clients.client_id Validation: Submitted
client_idvalues must be syntactically valid URLs using thehttporhttpsscheme. They must not contain a fragment delimiter (#) at all, even with no fragment content, and must not include userinfo (user:pass@). The authorization endpoint (GET, consent POST, and code-verification POST) rejects malformed values with HTTP 400 before creating an authorization code: plain-text bodyinvalid client_idfor structural failures andinvalid_clientfor validator failures. The token endpoint rejects malformed submissions with HTTP 400invalid_requestand content typeapplication/x-www-form-urlencoded(matching the existing missing-codecase). Operators can additionally restrict which clients are allowed by settingINDIEWEB_CLIENT_ID_VALIDATOR(see Configuration); the configured callable runs at every authorization/token path and on every Micropub request, so revoking a client takes effect immediately for previously-issued tokens. A misconfigured validator (the dotted path fails to import or the callable raises) fails closed at every call site. Storedclient_idvalues are not re-validated structurally on use, matching theredirect_urirule; the configured validator, however, IS re-applied on use, so pre-existing tokens whoseclient_idno longer satisfies operator policy are rejected with HTTP 403invalid_clienton the Micropub endpoint.redirect_uri Validation: Submitted
redirect_urivalues must be syntactically valid URLs using thehttporhttpsscheme. They must not contain a fragment delimiter (#) at all, even with no fragment content, and must not include userinfo (user:pass@). The authorization endpoint rejects malformed values with HTTP 400 before creating an authorization code; the token endpoint rejects malformed submissions withinvalid_grant. When comparing the value submitted at the token endpoint with the value stored alongside the authorization code, the scheme and host are compared case-insensitively while the path and query are compared verbatim.redirect_urivalues that already contain a query (e.g.?next=/x) are preserved whencodeandstateare appended.Scope Issuance Semantics: The authorization endpoint normalizes scope strings before showing them on the consent screen and before storing them on the
Authrow. Normalization splits on whitespace, de-duplicates while preserving first-seen order, and joins with single spaces; whitespace-only values become no scope. Unknown scopes are accepted and preserved after normalization because IndieAuth/Micropub scopes are extension-defined. The token endpoint issues the exact normalized scope stored with the auth code. If a token exchange includes ascopeparameter, that submitted value is normalized and must match the stored auth-code scope exactly. A mismatch returns HTTP 400invalid_grantwith content typeapplication/x-www-form-urlencodedand does not create or reissue a token. Omittingscopeon token exchange continues to issue the stored auth-code scope. An explicitly emptyscope=parameter normalizes to no scope, so it only succeeds when the auth code was issued with no scope.Per-Operation Scope Enforcement: The Micropub resource server enforces scopes per operation rather than treating
createas a master scope. The W3C Micropub Recommendation (§5 Scope) allows servers to define their own granular scopes; the names below are the project’s chosen policy and follow the conventional names that reference clients (Quill, Indigenous, Micropublish) request.POSTentry create requirescreate(the legacy aliaspostis still accepted);POST action=updaterequiresupdate;POST action=deleterequiresdelete;POST action=undeleterequiresundelete;GET ?q=sourcerequiresupdate(the spec does not define a separate read scope and the typical use case forq=sourceis “fetch a post to edit it”).GET ?q=config,GET ?q=syndicate-to, andGETwith noqonly require an authenticated token. Storedscopeis split on whitespace and compared as an exact token, socreateXYZdoes not satisfycreate. Scope failures return HTTP 403 with the plain-text bodyauthorization error. Theupdate,delete, andundeleteactions and theGET ?q=sourcequery dispatch into the configuredMicropubContentHandlerafter the scope check succeeds.
Configuration¶
Configure IndieAuth behavior in your Django settings:
# Auth code expiration time in seconds (default: 60)
INDIWEB_AUTH_CODE_TIMEOUT = 60
# Access token lifetime in seconds (default: 86400, i.e. 24 hours)
INDIEWEB_TOKEN_EXPIRES_IN = 86400
# Login URL for redirecting unauthenticated users
LOGIN_URL = "/accounts/login/"
Testing IndieAuth¶
Test your IndieAuth implementation using:
Web-based testers:
Micropub clients (for authorization):
Quill (https://quill.p3k.io/)
Indigenous for iOS/Android
Micropublish (https://micropublish.net/)
Unit tests:
def test_consent_screen_displays(client, user): client.login(username=user.username, password="password") response = client.get("/indieweb/auth/", { "me": "https://example.com", "client_id": "https://app.example.com", "redirect_uri": "https://app.example.com/callback", "state": "12345", "scope": "create" }) assert response.status_code == 200 assert "Authorization Request" in response.content.decode()
Example: Using IndieAuth with a Micropub Client¶
Configure your site’s homepage to advertise the endpoints:
<link rel="authorization_endpoint" href="https://mysite.com/indieweb/auth/"> <link rel="token_endpoint" href="https://mysite.com/indieweb/token/"> <link rel="micropub" href="https://mysite.com/indieweb/micropub/">
When a Micropub client tries to authenticate:
It discovers your endpoints from your homepage
Redirects you to the authorization endpoint
You see the consent screen and approve/deny
The client receives an auth code
The client exchanges the code for an access token
The client can now create posts using the token
Troubleshooting¶
- “Missing parameter” errors
Ensure all required parameters are provided:
me,client_id,redirect_uri,state- Consent screen not showing
Check that you’re logged in and all parameters are valid
- Auth code expired
Auth codes are only valid for 60 seconds. The client must exchange them quickly.
- No scopes shown on consent screen
This is normal for authentication-only flows. Scopes are only shown when the client requests permissions.