If you are building anything that needs a third party to act on a member's behalf — an AI connector, a mobile app, a desktop client, an integration a customer authorises — the instinct is to invent a token scheme. A long secret in a URL, an API key in a settings field, a bearer token you mint yourself.
Do not. Invision Community 5 ships a complete OAuth 2.1 authorization server, and almost nobody building on the platform seems to know it is there. It is not a stub. It supports PKCE, refresh tokens, per-client scopes, brute-force lockout and revocation, and it already has an AdminCP screen.
What is actually there
Four tables, all created by the core installer:
core_oauth_clients ← the registered clients core_oauth_server_access_tokens ← issued tokens, with status and scope core_oauth_server_authorization_codes ← codes, with code_challenge core_oauth_authorize_prompts ← in-flight consent screens
Two physical endpoints in the site root:
/oauth/authorize/ ← the consent screen /oauth/token/ ← the token exchange
And the AdminCP screen that manages it all, at System → Site Features → API → OAuth Clients.
PKCE is real
The client row has an oauth_pkce column taking S256, plain or none, and the authorization-codes table carries code_challenge and code_challenge_method. The token endpoint verifies it:
if ( $authorizationCode['code_challenge'] or $this->client->pkce !== 'none' )
{
if ( !$codeVerifier or !$authorizationCode['code_challenge'] )
{
return;
}
// ... S256 hash, then Login::compareHashes()
}
It also treats a re-used authorization code as an attack and revokes every token that code produced, which is the behaviour the specification asks for and the part people most often get wrong when writing their own.
Validating a bearer token in your own code
One public static call:
use IPS\Api\OAuthClient; $details = OAuthClient::accessTokenDetails( $token ); $member = \IPS\Member::load( $details['member_id'] ); $scopes = json_decode( $details['scope'], TRUE );
It throws UnderflowException if the token is unknown, and IPS\Api\Exception if it has expired or been revoked.
Four traps
1. It does not check whether the member is banned. accessTokenDetails() and validateAccessToken() check expiry and revocation and stop there — the REST dispatcher does the standing check separately. Somebody banned after they authorised keeps a working token until it expires. Check it yourself:
if ( $member->isBanned() or $member->members_bitoptions['validating'] )
{
// refuse
}
2. A client_credentials token has no member behind it. member_id is NULL. If your code assumes a member and calls Member::load( NULL ), you get a guest — so a permission check that should have failed quietly passes as "guest can see public content", or worse, your code falls back to the currently logged-in member. Decide explicitly what a memberless token may do, and if the answer is "nothing", refuse it.
3. The token format is <client_id>_<token> and it is split on the first underscore. accessTokenDetails() does explode( '_', $accessToken ) and takes elements 0 and 1. Client IDs generated by core are 32 hex characters, so this is safe — but if you ever create a client row programmatically, keep the ID hex. An underscore in a client ID silently breaks every token it issues.
4. Scopes are free text you define per client, and an empty endpoints array means no REST access. The scope definition stored on the client looks like this:
{
"myapp.read": { "description": "Read …", "endpoints": {} },
"myapp.write": { "description": "Post …", "endpoints": {} }
}
That endpoints map is what scopesCanAccess() consults to decide whether a scope may reach a core REST endpoint. Leaving it empty is a feature, not an oversight: the token then works for your endpoint and has no access to the REST API at all. That is least privilege for free.
Create the client for your users
Every field is on core's own OAuth screen, but it is six settings to get exactly right, and a wrong PKCE dropdown fails with a symptom ("it would not connect") that points nowhere. Insert the row yourself from your application's settings page and give the administrator a button:
Db::i()->insert( 'core_oauth_clients', array(
'oauth_client_id' => $clientId, // hex, 32 chars
'oauth_client_secret' => password_hash( $secret, PASSWORD_DEFAULT ),
'oauth_grant_types' => 'authorization_code',
'oauth_redirect_uris' => json_encode( $redirectUris ),
'oauth_pkce' => 'S256',
'oauth_choose_scopes' => 1, // the member ticks which scopes to grant
'oauth_ucp' => 1, // and can revoke it from their own settings
'oauth_scopes' => json_encode( $scopes ),
'oauth_enabled' => 1,
'oauth_prompt' => 'reauthorize',
) );
// The display name is a language string, not a column:
Lang::saveCustom( 'core', "core_oauth_client_{$clientId}", 'My Integration' );
oauth_choose_scopes is the one worth understanding. With it off, the member grants whatever the client asked for. With it on, they see a checklist and decide — which is the difference between "authorise this application" and "authorise this application to do these specific things".
Why this beats rolling your own
The security argument is the obvious one, and it is real: you are not going to write a better authorization-code flow in an afternoon than the one that already ships.
But the better argument is that it respects the site. The member authorising is a real member, signing in through the community's own login — so two-factor authentication applies, bans apply, group permissions apply, and their existing session is used. The administrator revokes access on a screen they already know, in a list alongside every other connected application, rather than on one you invented. And the member can cut it off themselves from their own account settings.
None of that is something you can bolt onto an API key.
Recommended Comments