Token Endpoints
Technical reference for the OAuth 2.0 token, refresh, and revocation endpoints.
Token lifecycle
Token endpoint
POST /api/oauth/tokenExchanges an authorization code for an access token, or refreshes an existing token.
Authorization Code grant
| Parameter | Type | Required | Description |
|---|---|---|---|
grant_type | string | Yes | Must be authorization_code |
code | string | Yes | The authorization code from the callback |
redirect_uri | string | Yes | Must match the URI used in the authorization request |
client_id | string | Yes | Your application's client ID |
code_verifier | string | Yes | The PKCE code verifier (plain-text, 43-128 chars) |
client_secret | string | Conditional | Required for confidential clients |
Response
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 7200,
"refresh_token": "def456...",
"scope": "openid courses:read students:read",
"created_at": 1710000000,
"school_id": "550e8400-e29b-41d4-a716-446655440000",
"school_subdomain": "demo"
}| Field | Description |
|---|---|
access_token | Bearer token for API requests |
token_type | Always Bearer |
expires_in | Token lifetime in seconds (default: 7200 / 2 hours) |
refresh_token | Token to obtain a new access token |
scope | Granted scopes (may be a subset of what was requested) |
school_id | The UUID of the authorized school |
school_subdomain | The subdomain of the authorized school |
Refresh Token grant
| Parameter | Type | Required | Description |
|---|---|---|---|
grant_type | string | Yes | Must be refresh_token |
refresh_token | string | Yes | A valid refresh token |
client_id | string | Yes | Your application's client ID |
client_secret | string | Conditional | Required for confidential clients |
Response
Same format as the authorization code grant response.
Refresh token rotation
Every successful refresh returns a new refresh_token in the response. The previous refresh token is immediately revoked.
Your client must persist the new refresh token and use it for the next refresh. Replaying an old token returns invalid_grant.
async function refreshAccessToken(storedRefreshToken) {
const res = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: storedRefreshToken,
client_id: CLIENT_ID,
// Include client_secret for confidential clients:
// client_secret: CLIENT_SECRET,
}),
});
if (!res.ok) throw new Error(`Refresh failed: ${res.status}`);
const { access_token, refresh_token, expires_in } = await res.json();
// Always persist the latest refresh_token
await store.set("oauth_tokens", {
access_token,
refresh_token: refresh_token ?? storedRefreshToken,
expires_at: Date.now() + expires_in * 1000,
});
return access_token;
}Revocation endpoint
POST /api/oauth/revokeRevokes an access token or refresh token per RFC 7009.
| Parameter | Type | Required | Description |
|---|---|---|---|
token | string | Yes | The token to revoke |
token_type_hint | string | No | access_token or refresh_token |
client_id | string | Yes | Your application's client ID |
The endpoint always returns 200 OK, even if the token was already revoked or invalid. This prevents token-guessing attacks.
Introspection endpoint
POST /api/oauth/introspectReturns metadata about a token per RFC 7662.
| Parameter | Type | Required | Description |
|---|---|---|---|
token | string | Yes | The token to introspect |
client_id | string | Yes | Your application's client ID |
Response (active token)
{
"active": true,
"scope": "openid courses:read",
"client_id": "abc123",
"token_type": "Bearer",
"exp": 1710007200
}Response (inactive/invalid token)
{
"active": false
}UserInfo endpoint
GET /api/oauth/userinfo
POST /api/oauth/userinfoReturns identity claims about the authenticated user per OpenID Connect Core 1.0 §5.3. The openid scope is required.
Request
Include the access token as a Bearer token in the Authorization header:
Authorization: Bearer <access_token>Response
The claims returned depend on the scopes granted to the token:
{
"sub": "550e8400-e29b-41d4-a716-446655440000",
"name": "Jane Doe",
"email": "jane@example.com",
"email_verified": true,
"org_id": "7a1e9d5f-3c2b-4a8e-9d3f-0c2b6e8d4f1a",
"roles": ["owner", "teacher"]
}openid is always required to call this endpoint — the "Extra scope"
column below says which additional scope to request if you want a
particular claim to appear (— means no extra scope needed beyond
openid).
| Claim | Extra scope | Present when | Description |
|---|---|---|---|
sub | — | Always | Unique user identifier (UUID) |
name | profile | profile scope was granted | User's display name |
email | email | email scope was granted | User's email address |
email_verified | email | email scope was granted | Whether the email has been verified |
org_id | — | the token has a school context | School identifier the token is bound to. Use this to scope tenant data by Loopwise school. |
roles | — | the token has a school context | User's roles on the bound school (may be an empty array if the user has no roles there). Ordered by privilege, so roles[0] — when the array is non-empty — is the highest-privilege role and is suitable as a stable "primary role" for onboarding mapping. |
org_id and roles are OIDC extension claims (OIDC Core §5.1.2), present whenever the access token carries a school binding — true for every standard OAuth login flow. They are advertised in /.well-known/oauth-authorization-server under claims_supported.
Role ordering
roles is sorted by an explicit privilege priority:
owner > manager > admin > teacher > teaching_assistant > studentTenant-defined custom roles (created from your school's admin UI) sort after the built-in roles, alphabetically among themselves. The ordering is a stable contract: when roles is non-empty, roles[0] is the highest-privilege role the user holds. Callers must still handle the empty-array case (a school-bound token for a user with no roles on that school).
Error responses
| HTTP Status | Error | When |
|---|---|---|
| 401 | invalid_token | Missing, expired, or revoked token |
| 403 | invalid_scope | Token does not include the openid scope |
Error responses
All token endpoints return errors in the standard OAuth 2.0 format:
{
"error": "invalid_grant",
"error_description": "The authorization code has expired or has already been used."
}| Error | HTTP Status | Description |
|---|---|---|
invalid_request | 400 | Missing or malformed parameter |
invalid_client | 401 | Client authentication failed |
invalid_grant | 400 | Code expired, already used, or verifier mismatch |
unauthorized_client | 400 | Client not authorized for this grant type |
unsupported_grant_type | 400 | Only authorization_code and refresh_token are supported |