Integration guide
OneLogin is a standard OpenID Connect provider. Any OIDC or OAuth2 client library can integrate against it — the protocol section below is framework-agnostic; the code examples further down cover any PHP app, with a Laravel-specific fast path.
Building this for a store platform or any stack with no ready-made library? The three raw HTTP calls are the whole contract, and the error reference lists every response the server can send. You can also call the endpoints with sandbox credentials straight from this page, in the Try it console further down.
Working with an AI coding agent? Point it at https://1login.io/docs/agents.md — a compact Markdown brief of this contract written for agents (details); https://1login.io/llms.txt is the machine-readable index of these docs.
Quick start
#The whole integration in seven steps. Each step links to the section that carries the details.
- Get your credentials: a client ID, a client secret and a registered redirect URI (what you'll need; an admin hands them over, see the admin guide).
- Redirect the browser to the authorization endpoint with a random state and a PKCE S256 challenge (the flow itself).
- On your callback, check error before code, then check that state matches (the three HTTP calls).
- Exchange the code for tokens from your server, with the code_verifier (the token call).
- Ask the userinfo endpoint who the user is (the claims).
- Match or create your local user, linking an existing account by email only when email_verified is true (log in, link, or create).
- Test against the sandbox first; going live is a configuration swap (sandbox, then go live).
What you'll need
#From the admin guide's one-time reveal: a client ID, a client secret, and a redirect URI. Everything else below is fixed for this OneLogin instance.
Rolling a secret: when an admin rotates your application's secret, they choose how long the old one keeps working — immediately, or an overlap window up to 7 days — so update every server to the new secret and, once they're all switched, ask the admin to revoke the old one early rather than waiting it out. The admin panel shows them exactly when the old secret was last actually used, so they'll know it's safe.
- Issuer
- https://1login.io
- Discovery document
- GEThttps://1login.io/.well-known/openid-configuration
- Authorization endpoint
- GEThttps://1login.io/oauth/authorize
- Token endpoint
- POSThttps://1login.io/oauth/token
- Userinfo endpoint
- GEThttps://1login.io/oauth/userinfo
- JWKS
- GEThttps://1login.io/.well-known/jwks.json
- End-session endpoint
- GEThttps://1login.io/oauth/logout (RP-initiated logout). Send the user's browser here with id_token_hint (the id_token you were issued), optionally post_logout_redirect_uri (must match one of your registered redirect URIs) and state: with a valid hint the user is signed out at once and sent back to that URI, with state appended. Without a hint OneLogin asks the user to confirm before signing them out, so a logout link can never end someone's session silently. Signing out also revokes tokens: with a valid hint, the access and refresh tokens issued to your application for that user; after the user confirms without a hint, every token issued to that user by any application. An access token you already validate locally stays usable until it expires (15 minutes), so treat a failed refresh as the signal that the user is signed out.
- Sign-out-everywhere endpoint
- POSThttps://1login.io/oauth/revoke-all (a OneLogin extension, not part of OIDC, so it is not in the discovery document). For a confidential application that holds a user's tokens somewhere it cannot reach one by one and needs to sign them out everywhere: POST from your server, authenticated like the token endpoint (client_secret_basic or client_secret_post), with the user's sub. OneLogin revokes every access and refresh token issued to your application for that user, so their next refresh is refused (invalid_grant); other applications' tokens and the user's OneLogin session are untouched, and an access token you already validate locally works until it expires (15 minutes). Answers 200 with the counts it newly revoked (zero on a repeat, or for a sub that matches no user in your tenant, so a zero says nothing about whether an account exists), safe to retry; a public client, unknown client, wrong secret, or a suspended application (or one of a suspended tenant) gets 401 invalid_client, a missing sub gets 400, and it shares the token endpoint's rate limit. Send the credentials and sub in the Authorization header or the request body, never the URL: a request with client_id, client_secret or sub in the query string is refused with 400 invalid_request.
- Redirect URI
- https:// required on a live tenant; under its sandbox twin, http:// is also allowed on localhost, 127.0.0.1 and .test / .localhost hosts.
- Scopes
- openid profile email
- Grant types
- authorization_code, refresh_token
- Token endpoint auth
- client_secret_basic, client_secret_post
- PKCE
- Required — S256, for every client, confidential ones included. An authorize request without code_challenge and code_challenge_method=S256 (or with plain) is rejected with a 400.
- sub claim
- An opaque, stable per-user identifier — never a sequential ID, safe to store as a foreign key.
Use it with an AI coding agent
#Building the integration with an AI coding assistant? Give it this link. It is a compact Markdown brief of the contract: the endpoints to call, what to ask your admin for, the changes to make in your own system, and what never to do. It contains no secrets and no real client IDs, and it tells the agent to ask you for the sandbox credentials instead of inventing them.
https://1login.io/docs/agents.md
Tools that look for an index find one at https://1login.io/llms.txt. Both are plain text served from this deployment and generated from its configuration, so the issuer and lifetimes in them match this server. An agent that cannot follow links can read the same brief as a single file at https://1login.io/llms-full.txt. The brief is shorter than this guide on purpose: it links back here for the code samples, the error reference and the rate limits.
The flow itself
#This part never changes, whatever language or framework is on the other end — it's plain OAuth2/OIDC over HTTP.
The numbers match the steps below. Dashed arrows are responses.
- Generate a random state value, store it (session), redirect the browser to the authorization endpoint with client_id, redirect_uri, response_type=code, scope=openid profile email, state, and the PKCE pair: code_challenge (the base64url-encoded SHA-256 of a random code_verifier you generate and keep in the session) with code_challenge_method=S256.
- User logs in at OneLogin, consents (unless the application is first-party).
- OneLogin redirects back to your redirect URI with ?code=...&state=....
- Check the returned state matches what you stored — CSRF protection; skipping this is the most common OIDC integration mistake.
- Exchange the code server-to-server: POST to the token endpoint with grant_type=authorization_code, code, redirect_uri, client_id, client_secret, and the code_verifier from step 1 — the token request fails without it. The code is single-use and valid for 10 minutes.
- You get back access_token, id_token, refresh_token.
- Call the userinfo endpoint with Authorization: Bearer <access_token> to get the user's claims (sub, email, name, email_verified, plus env — live or sandbox — and tenant, your OneLogin tenant's slug; both also appear on the id_token) — simpler and safer than verifying the id_token JWT client-side, since you're asking the server "who is this" directly rather than parsing/validating a signature yourself.
- Look up or create a local user keyed by sub (the exact rules for linking an existing account are under log in, link, or create).
No framework — the three HTTP calls
#No library at all? The whole integration is one browser redirect and two server-to-server calls, followed by your own match-or-create step. This works in any language and inside any platform that lets you run server-side code and keep a session. The examples use this server's own address as the issuer; angle-bracket values are placeholders for yours.
1. Send the browser to the authorization endpoint
#Generate a random state and a PKCE code_verifier, keep both in the visitor's session (server-side), then redirect the browser here. The query is shown one parameter per line; send it as a single URL-encoded query string.
GET https://1login.io/oauth/authorize
?response_type=code
&client_id=<your client id>
&redirect_uri=<your registered redirect URI, URL-encoded>
&scope=openid%20profile%20email
&state=<random value, stored in the visitor's session>
&code_challenge=<base64url( SHA-256( code_verifier ) )>
&code_challenge_method=S256
&nonce=<random value, optional - echoed back in the id_token>
If you have no PKCE helper, this shell snippet produces a valid pair (your own language's crypto library does the same in a few lines):
# A code_verifier (43-128 characters from A-Z a-z 0-9 - . _ ~) and its S256 challenge.
CODE_VERIFIER=$(openssl rand -base64 48 | tr -d '=+/\n' | cut -c1-64)
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
2. Exchange the code, from your server
#After the user signs in, the browser lands on your redirect_uri with ?code=...&state=.... Check state, then make this call. The client secret goes only in this server-side request: never in browser JavaScript and never in a URL.
# Credentials in the form body (client_secret_post)
curl -sS -X POST https://1login.io/oauth/token \
-H 'Accept: application/json' \
-d grant_type=authorization_code \
--data-urlencode "code=$CODE" \
--data-urlencode "redirect_uri=$ONELOGIN_REDIRECT_URI" \
--data-urlencode "client_id=$ONELOGIN_CLIENT_ID" \
--data-urlencode "client_secret=$ONELOGIN_CLIENT_SECRET" \
--data-urlencode "code_verifier=$CODE_VERIFIER"
# The same call with the credentials in an Authorization header (client_secret_basic):
# drop client_id and client_secret from the body and add
# -u "$ONELOGIN_CLIENT_ID:$ONELOGIN_CLIENT_SECRET"
A successful answer (abridged):
{
"token_type": "Bearer",
"expires_in": 900,
"access_token": "eyJ...",
"refresh_token": "def50...",
"id_token": "eyJ..."
}
3. Ask who the user is
#curl -sS https://1login.io/oauth/userinfo \
-H 'Accept: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN"
The answer is the claims object: sub, name, email, email_verified, env and tenant.
4. Match or create your local user
#The same steps in order, with no framework and no types. Step 6 is the part that is easy to get wrong: see the rule about linking by email.
1. On your callback, if the query has `error`, stop: the user cancelled. (Check this before `code`.)
2. Compare the returned `state` with the one you stored. Different or missing: stop.
Delete the stored `state` either way.
3. POST the code and the stored code_verifier to the token endpoint, with your client credentials
and the same redirect_uri you sent in step 1.
Any non-2xx answer: show "sign-in failed" and log the status (never the code or the secret).
4. GET the userinfo endpoint with the access token. You now hold the claims.
5. Look up YOUR user where onelogin_id = claims.sub.
found -> go to 8
6. Not found: look up YOUR user where email = claims.email.
found AND claims.email_verified is exactly true
-> store claims.sub as that user's onelogin_id; go to 8
found, but email_verified is not true -> do NOT link and do NOT create a duplicate.
Send the visitor to your own login with the message
"An account with this email already exists - sign in
with your existing password, then link OneLogin from
your account page." Stop.
7. Not found by either: create YOUR user from the claims
(onelogin_id = claims.sub, name, email, no local password); go to 8.
8. Log that user in with your normal login routine, and make it start a fresh session id.
Where these steps say "look up / create a user" and "log them in", use your store's own user entity and login routine — ask your team which those are. The HTTP calls and the ordering above are the whole contract.
Option A — plain PHP, any framework
#Use league/oauth2-client — vendor-neutral, works in Symfony, WordPress, or bare PHP.
composer require league/oauth2-client
session_start();
$provider = new \League\OAuth2\Client\Provider\GenericProvider([
'clientId' => getenv('ONELOGIN_CLIENT_ID'),
'clientSecret' => getenv('ONELOGIN_CLIENT_SECRET'),
'redirectUri' => getenv('ONELOGIN_REDIRECT_URI'),
'urlAuthorize' => getenv('ONELOGIN_ISSUER').'/oauth/authorize',
'urlAccessToken' => getenv('ONELOGIN_ISSUER').'/oauth/token',
'urlResourceOwnerDetails' => getenv('ONELOGIN_ISSUER').'/oauth/userinfo',
'scopes' => 'openid profile email',
// PKCE is required by OneLogin for every client, confidential ones included.
'pkceMethod' => \League\OAuth2\Client\Provider\GenericProvider::PKCE_METHOD_S256,
]);
if (! isset($_GET['code'])) {
// Build the URL FIRST: it generates the state and the PKCE verifier (and adds
// code_challenge + code_challenge_method=S256), which are only readable afterwards.
$authorizationUrl = $provider->getAuthorizationUrl();
$_SESSION['oauth2state'] = $provider->getState();
$_SESSION['oauth2pkce'] = $provider->getPkceCode();
header('Location: '.$authorizationUrl);
exit;
}
if (empty($_GET['state']) || $_GET['state'] !== ($_SESSION['oauth2state'] ?? null)) {
unset($_SESSION['oauth2state']);
exit('Invalid state.');
}
unset($_SESSION['oauth2state']);
// Restore the verifier saved before the redirect; getAccessToken() sends it as code_verifier.
$provider->setPkceCode($_SESSION['oauth2pkce'] ?? '');
unset($_SESSION['oauth2pkce']);
$token = $provider->getAccessToken('authorization_code', ['code' => $_GET['code']]);
$user = $provider->getResourceOwner($token)->toArray();
// $user['sub'], $user['email'], $user['name'], $user['email_verified']
That's the whole integration for any plain-PHP app — no Laravel, no OneLogin-specific SDK.
Option B — Laravel (recommended: Socialite)
#Laravel Socialite already has a documented extension mechanism for any OIDC/OAuth2 provider — the class below drops into any Laravel app unmodified.
1. app/Socialite/OneLoginProvider.php
<?php
namespace App\Socialite;
use Laravel\Socialite\Two\AbstractProvider;
use Laravel\Socialite\Two\ProviderInterface;
use Laravel\Socialite\Two\User;
class OneLoginProvider extends AbstractProvider implements ProviderInterface
{
protected $scopes = ['openid', 'profile', 'email'];
protected $scopeSeparator = ' ';
// Required: OneLogin rejects any authorize request without a PKCE S256 challenge (even for
// confidential clients). Socialite's built-in PKCE is S256, keeps the verifier in the
// session between the redirect and the callback, and sends it with the token request.
protected $usesPKCE = true;
protected function getAuthUrl($state): string
{
return $this->buildAuthUrlFromBase(rtrim(config('services.onelogin.issuer'), '/').'/oauth/authorize', $state);
}
protected function getTokenUrl(): string
{
return rtrim(config('services.onelogin.issuer'), '/').'/oauth/token';
}
protected function getUserByToken($token): array
{
$response = $this->getHttpClient()->get(
rtrim(config('services.onelogin.issuer'), '/').'/oauth/userinfo',
['headers' => ['Authorization' => 'Bearer '.$token]]
);
return json_decode((string) $response->getBody(), true);
}
protected function mapUserToObject(array $user): User
{
return (new User)->setRaw($user)->map([
'id' => $user['sub'],
'name' => $user['name'] ?? null,
'email' => $user['email'] ?? null,
'avatar' => $user['picture'] ?? null,
]);
}
}
2. Register it — AppServiceProvider::boot()
use Laravel\Socialite\Facades\Socialite;
use App\Socialite\OneLoginProvider;
Socialite::extend('onelogin', fn ($app) =>
Socialite::buildProvider(OneLoginProvider::class, $app['config']['services.onelogin'])
);
3. config/services.php
'onelogin' => [
'issuer' => env('ONELOGIN_ISSUER'),
'client_id' => env('ONELOGIN_CLIENT_ID'),
'client_secret' => env('ONELOGIN_CLIENT_SECRET'),
'redirect' => env('ONELOGIN_REDIRECT_URI'),
],
4. Two routes, one controller pair — the only genuinely app-specific part
Put them in routes/web.php, not routes/api.php, and don't call ->stateless(): Socialite keeps the state and the PKCE verifier in the session between the two requests.
Route::get('/auth/onelogin/redirect', fn () => Socialite::driver('onelogin')->redirect())
->name('onelogin.redirect');
Route::get('/auth/onelogin/callback', function () {
$oneLoginUser = Socialite::driver('onelogin')->user();
$user = User::firstOrCreate(
['onelogin_id' => $oneLoginUser->getId()],
['name' => $oneLoginUser->getName(), 'email' => $oneLoginUser->getEmail()]
);
Auth::login($user); // or issue a Sanctum/Passport token, whatever this app already does
return redirect('/');
});
Whatever the app already does after login (start a session, mint a Sanctum token, redirect to a frontend with a token in the URL fragment) stays exactly as it is today — this only changes how the user's identity was established, not what happens afterward.
5. The actual button — one line in whichever Blade view has the login form
<a href="{{ route('onelogin.redirect') }}">Sign in with OneLogin</a>
That's the only UI-side change. It's a plain link, not a form — clicking it is a full-page GET to your own onelogin.redirect route (step 4), which immediately redirects on to OneLogin; no JavaScript, API call, or widget involved. Add it to the one view that should offer this option — a login page, a nav bar — not to every Blade file, the same way an existing "Sign in with Google" button would sit next to it.
What you get back, and what can go wrong
#The examples above are the happy path. Two things they skip over on purpose, to stay readable — covered properly here.
On success
#Laravel — Socialite::driver('onelogin')->user() returns a Laravel\Socialite\Two\User: ->getId() is the sub claim (opaque ULID — this is what to store as your local foreign key, never the name or email, since those can change and sub doesn't), plus ->getName(), ->getEmail(), ->getAvatar(), and ->user for the raw claims array underneath (including email_verified).
Plain PHP — $provider->getResourceOwner($token)->toArray() returns the raw claims array directly: sub, name, email, email_verified, picture, plus env and tenant.
env is live or sandbox (a sandbox application's tokens always say sandbox), and tenant is the slug of the OneLogin tenant the user belongs to — a sandbox user's is the sandbox twin's own slug. Both are always present, need no extra scope, and are safe to check if your app should only ever accept live (or only sandbox) logins.
email_verified will effectively always be true coming from OneLogin, since it enforces email verification during its own registration flow before an account can log in at all — but check it rather than assuming, in case that ever changes. It is the claim that decides whether an existing local account may be linked by email: see log in, link, or create.
Some users sign in with a passkey instead of a code or password — this is invisible to your application: no claim says so, the token shape is identical either way, and nothing on your side needs to change.
If you're building a resource server that validates the access-token JWT locally instead of calling userinfo (e.g. an API that only ever sees the bearer token): verify the RS256 signature against the JWKS above (select the key by the JWT's kid header, re-fetch once on an unknown kid before rejecting), then check exp, aud (your client ID) and iss (this issuer — the access token carries the same iss the id_token does). Also check the header's typ: every access token is marked at+jwt (RFC 9068) while an id_token carries JWT, so requiring at+jwt keeps an id_token from being accepted as an access token.
On failure — three distinct shapes, each needs its own handling
#- The user cancels or denies consent. The callback URL gets ?error=access_denied&state=... instead of ?code=.... Check for error before assuming a code exists.
- state doesn't match. CSRF protection tripped — usually a stale link or an expired session, occasionally a real attack. Laravel Socialite throws Laravel\Socialite\Two\InvalidStateException automatically inside ->user(); the plain-PHP example above already checks this manually.
- The token exchange itself fails. A wrong or rotated-out client secret (during the overlap an admin chose, the previous secret keeps working), or the code was already used or has expired (codes are single-use and valid for 10 minutes). Laravel: a GuzzleHttp\Exception\ClientException thrown from ->user(). Plain PHP: a League\OAuth2\Client\Provider\Exception\IdentityProviderException thrown from getAccessToken().
Laravel callback, handling all three:
Route::get('/auth/onelogin/callback', function () {
if (request('error')) {
return redirect('/login')->with('error', 'Sign-in was cancelled.');
}
try {
$oneLoginUser = Socialite::driver('onelogin')->user();
} catch (\Laravel\Socialite\Two\InvalidStateException $e) {
return redirect('/login')->with('error', 'That sign-in link expired - please try again.');
} catch (\GuzzleHttp\Exception\ClientException $e) {
report($e);
return redirect('/login')->with('error', 'Sign-in failed - please try again.');
}
// success path - see "what to do with the success data" below
});
Plain PHP, added to the earlier example:
if (isset($_GET['error'])) {
exit('Sign-in was cancelled.');
}
try {
// $provider->setPkceCode(...) already ran above - it must precede this call.
$token = $provider->getAccessToken('authorization_code', ['code' => $_GET['code']]);
} catch (\League\OAuth2\Client\Provider\Exception\IdentityProviderException $e) {
exit('Sign-in failed - please try again.');
}
Every response the server can send for a failed call, with its status code and body, is in the error reference.
What to do with the success data — log in, link, or create
#OneLogin's job ends at "here's who this is" (the claims above). What happens next is entirely the integrating app's own logic — the same three-way lookup any social-login integration needs, with one rule that must not be skipped:
Never link to an existing local account on a bare email match. Require email_verified === true in the claims first. An email address that only matches, without a verified claim behind it, is not proof the person owns that local account; linking on it is how an account takeover happens.
- Look up a local user by onelogin_id (the sub). Found → this is a returning user, log them in, done.
- Not found — look up by email. Found and email_verified is exactly true → they had a local account before OneLogin was added (or logged in some other way before); link onelogin_id onto that existing row rather than creating a duplicate account, then log them in.
- The careful case: found by email, but email_verified is not true → do not link and do not create a duplicate. Send the user to your own login with a message like "an account with this email already exists; sign in with your existing password, then link OneLogin from your account page". OneLogin verifies emails at registration, so this branch should effectively never fire; it exists so that a future change to the claims can't silently turn into an account takeover.
- Not found by either → create a new local user from the claims (onelogin_id, name, email, no local password — they authenticate via OneLogin from now on), then log them in.
"Log them in" means whatever the app already does for any other login method — start a session, mint a Sanctum/Passport/JWT token, redirect an SPA with a token. OneLogin only established who the person is; it doesn't change how the app tracks that they're logged in.
Laravel — read the raw claims from $oneLoginUser->user and branch on them:
$claims = $oneLoginUser->user; // the raw claims array: sub, email, email_verified, ...
$user = User::firstWhere('onelogin_id', $oneLoginUser->getId());
if (! $user) {
$existing = User::firstWhere('email', $oneLoginUser->getEmail());
if ($existing && ($claims['email_verified'] ?? false) === true) {
// A local account with this email, and OneLogin vouches for the address: link it.
$existing->update(['onelogin_id' => $oneLoginUser->getId()]);
$user = $existing;
} elseif ($existing) {
// The careful case: same email, but OneLogin did NOT verify it. Do not link, and do not
// create a duplicate - send them to your own login instead.
return redirect('/login')->with('error',
'An account with this email already exists. Sign in with your existing password, '
.'then link OneLogin from your account page.');
} else {
$user = User::create([
'onelogin_id' => $oneLoginUser->getId(),
'name' => $oneLoginUser->getName(),
'email' => $oneLoginUser->getEmail(),
'password' => null,
]);
}
}
Auth::login($user); // also regenerates the session id (plain PHP: session_regenerate_id(true))
Plain PHP — the same gate, on the same array key: only link when ($user['email_verified'] ?? false) === true on the array from getResourceOwner($token)->toArray().
What actually differs between the two options
#Nothing about OneLogin changes — same URLs, same tokens, same claims either way. The only difference is how much of the OAuth2 mechanics a library handles: league/oauth2-client in plain PHP does the redirect/state/exchange steps generically; Socialite in Laravel wraps the same steps behind a nicer ->redirect()/->user() API. That's the entire benefit of OneLogin being standard OIDC rather than a custom API — one integration shape works everywhere.
Linking to the account page
#OneLogin hosts a self-service page where a signed-in user can set or change their password, add and verify a US mobile number, choose whether their sign-in codes arrive by email or by text, and choose whether their password is asked at sign-in or a code alone is enough — you don't need to build any of that yourself. Link to it with a plain <a href> (a top-level navigation, not a fetch/XHR):
https://1login.io/account/start?client_id=<your client id>&return_to=<an absolute URL on one of your registered redirect-URI origins>
return_to must be an absolute URL whose origin (scheme + host + port) matches one of the redirect URIs you registered for this application — any path, query string or fragment on that origin is fine, so your own account page's URL works even though it isn't your OAuth callback path. If the OneLogin session is already live, the user lands there immediately; otherwise they sign in first and are returned there afterwards. A link whose return_to origin isn't registered (or whose client_id is unknown) gets a generic 400 "Bad Request" page: the reason is deliberately not shown to the user, so check the origin against your registered redirect URIs. Only the 403 suspension page names its reason — client_suspended or tenant_suspended (the same suspension checks as /oauth/authorize). The account page now also covers sign-in code delivery (email or a verified US mobile number) — this is entirely self-service on OneLogin's side and changes nothing about your own integration's contract. Once a user has chosen text codes there, they can also start a sign-in by entering that number instead of their email on the hosted login page — again, no contract change on your side; the claims and tokens you receive are identical.
Error reference
#What the server actually answers when a call goes wrong. The token endpoint's OAuth errors are JSON with an error key; the server's own checks (suspension, authentication, rate limits) use a message key instead, so read error first and fall back to message.
| Where | Trigger | What the server returns |
|---|---|---|
| authorize | A redirect_uri that is not registered on the application, or differs from the registered one | 401, an HTML error page. The browser is never redirected to that URI and no code is issued. |
| authorize | No PKCE challenge, or a method other than S256 (plain included) | 400 JSON, even though this is a browser endpoint:
{"error":"invalid_request", |
| authorize | The application is suspended, or its tenant is | 403, a branded HTML page that names the reason: client_suspended or tenant_suspended |
| your callback | The user denied consent | A redirect to your redirect_uri with ?error=access_denied |
| token | Unknown client, or a wrong or rotated-out secret from a confidential client | 401
{"error":"invalid_client", |
| token | An expired (older than 10 minutes) or already-used code, a wrong code_verifier, or an expired, revoked or already-used refresh token | 400
{"error":"invalid_grant", ...}
The description and hint vary with the cause. A replayed code, or a reused rotated refresh token, also revokes that user's tokens on your application (reuse detection). A repeat of a refresh within 60 seconds of the rotation, the usual lost-response case, is refused without that revocation. |
| token | The application is suspended, or its tenant is | 403
{"message":"client_suspended"}
{"message":"tenant_suspended"}
A different shape from the OAuth errors above: this is the server's own check, not an OAuth error object. |
| userinfo | A missing, expired or revoked access token | 401
{"message":"Unauthenticated."} |
| any | Rate limited (see rate limits) | 429 with a Retry-After header (seconds) and X-RateLimit-* headers. The machine endpoints answer JSON,
{"message":"Too Many Attempts."}
Discovery and JWKS answer JSON only when you send Accept: application/json; browser pages get a branded HTML page. |
| /account/start | A return_to whose origin is not registered, or an unknown client_id | 400, a generic "Bad Request" page. The reason (invalid_return_to) is not shown to the user. Only the 403 suspension page names its reason. |
A malformed token request gets 400 invalid_request instead of invalid_grant: a missing or malformed code_verifier (43 to 128 characters from A-Z a-z 0-9 - . _ ~), or a redirect_uri that differs from the one sent to authorize, or a confidential client that sends no client_secret at all (a wrong secret is still invalid_client).
Rate limits
#Limits are per minute and enforced for every caller; a request over the limit gets the 429 described in the error reference.
| Endpoint | Limit | Counted per |
|---|---|---|
| /oauth/authorize, /.well-known/openid-configuration and /.well-known/jwks.json | 30 per minute | IP address. One bucket shared by all three. |
| /oauth/token, /oauth/introspect, /oauth/revoke and /oauth/revoke-all | 60 per minute (300 for first-party applications) | Client ID and the caller's IP. A client ID that does not exist shares one 60-per-minute bucket per IP. |
| Refresh-token grants, on top of the row above | 10 per minute | User. A refresh token the server cannot read is limited to 15 per minute per IP instead. |
| /oauth/userinfo | 60 per minute | User (IP address when there is no valid token) |
In practice: every server-side sign-in and refresh your store makes shares one 60-per-minute bucket per store-server IP. A burst of sign-ins above that sees 429; wait for the Retry-After value and do not retry in a hot loop.
Lifetimes
#| What | Lifetime | Notes |
|---|---|---|
| Authorization code | 10 minutes | Single use. Presenting it a second time is refused and revokes what the first use issued. |
| Access token and id_token | 15 minutes | Both carry exp. A token you validate locally works until then, even after a sign-out. |
| Refresh token | 7 days | Rotated on every use: each refresh returns a new refresh token with a fresh 7 days, and the old one stops working. Reuse of an old one is detected (see the error reference). |
| OneLogin browser session | 30 minutes idle, 12 hours absolute | Decides whether /oauth/authorize can skip sign-in. It does not by itself end tokens you already hold. |
Sessions and sign-out
#- OneLogin sends no front-channel or back-channel logout to the applications that use it, and there is no such endpoint. Your store session is yours: it lasts until you end it.
- Signing out of your store does not sign the user out of OneLogin. If their OneLogin session is still live, the next "Sign in with OneLogin" completes without asking for credentials. Send them to the end-session endpoint as well, or add prompt=login to the authorize request, if that is not what you want.
- A sign-out at OneLogin revokes tokens: with a valid id_token_hint it revokes your application's tokens for that user; a confirmed sign-out without a hint revokes every token the user holds, at every application. Treat a failed refresh (invalid_grant) as "the user is signed out".
- An access token you validate locally keeps working until its exp, at most 15 minutes after it was issued.
Optional authorize parameters
#- nonce — supported and tested. Send a random value; it comes back in the id_token. Send it, and check that it matches if you rely on the id_token.
- prompt=login — supported and tested. It forces the user to sign in again even when a OneLogin session exists (the "use a different account" lever). It is bound to the request that carried it: a new authorize request with prompt=login always asks again.
- login_hint — ignored. No error, and the email field is not prefilled.
- prompt=none and prompt=consent — present in the underlying framework but not part of OneLogin's tested contract. Do not build on them.
- max_age — not supported.
Try it
#Call this server's endpoints from your browser, with sandbox credentials, and see exactly what a client library would see: the status, the timing, the headers and the body. Every panel starts collapsed. Nothing is sent until you press a button.
Sandbox credentials only.
- The console refuses to send a live application's credentials to the token and revoke-all endpoints, and it refuses a token whose env claim is live. That is a safety net, not a security boundary.
- Never paste a production secret into any web page, this one included. Run live calls from your own server; every request panel has a Copy as curl button for exactly that.
- What you type stays in this page's memory only. It is never saved (no cookies, no browser storage) and the secret fields are cleared when you leave the page. Requests go straight from your browser to this server, with nothing in between.
- These are real requests: they spend the real rate limits. Authorize, discovery and JWKS share one bucket of 30 requests a minute per IP; token and revoke-all allow 60 a minute per application and IP; userinfo allows 60 a minute per user.
PKCE, state and nonce helper
Every authorization request needs a one-time PKCE pair, a state value and, for OpenID Connect, a nonce. Generate them here (32 random bytes each, S256 challenge, all done in your browser with the Web Crypto API) and the authorize and token panels below are filled in for you. Your own server generates fresh values for every sign-in.
Your browser only offers Web Crypto on secure pages. Open this page over https to use the helper.
Discovery GET /.well-known/openid-configuration
The document an OpenID Connect library reads at startup: endpoint URLs, supported scopes and claims, signing algorithms. No credentials needed. This shares the 30-a-minute bucket with authorize and JWKS.
JWKS GET /.well-known/jwks.json
The public keys this server signs tokens with. If you verify an id_token or access token locally, check its signature against the key whose kid matches the token header. No credentials needed.
Authorize build the /oauth/authorize URL
Builds the sign-in URL and opens it in a new tab. This panel never fetches it: sign-in is a browser flow. After you sign in, the browser lands on your redirect URI with ?code=...&state=.... A sandbox application accepts an http://localhost redirect URI even with nothing listening there: you will see a connection-refused page, but the code is still in the address bar. Copy the whole address (or just the code value) into the token panel below. Codes are single-use and expire after 10 minutes.
Token POST /oauth/token
Exchanges an authorization code (or a refresh token) for tokens. Your store's server makes this call, never the browser: this panel is for seeing the exact response. Sandbox applications only. A live application's credentials get a 403 console_live_client here.
Userinfo GET /oauth/userinfo
Returns the signed-in user's claims for an access token. Access tokens last 15 minutes. This panel refuses a token whose env claim is live.
End session build the /oauth/logout URL
Builds the sign-out URL and opens it in a new tab. This panel never fetches it: sign-out is a browser flow. With a valid id_token_hint from a browser that has a live session here, the user is signed out and that application's tokens are revoked. Without a hint the user is shown a confirmation page, and confirming it signs them out of everything and revokes their tokens for all applications. The hint travels in the URL, which is how OpenID Connect sign-out works.
Revoke all POST /oauth/revoke-all
Signs one user out of your application everywhere: it revokes that user's access and refresh tokens on the calling application only. Identify the user by the sub claim. Sandbox applications only. Credentials and sub go in the body or the Authorization header, never in the URL.
Security checklist for implementers
#- Regenerate your session id at login (Laravel's Auth::login() does; in plain PHP call session_regenerate_id(true)).
- If you redirect somewhere after login, check that target against an allowlist. An unchecked return URL is an open redirect.
- Never log tokens, codes or secrets.
- Use state and PKCE. Both, always.
- Use https:// redirect URIs in production.
- The client secret never leaves your server: never in browser JavaScript, never in a URL.
- Key your local accounts on sub, never on the email address. Link an existing account by email only when email_verified is true.
- Check env if your application should only accept live (or only sandbox) sign-ins. If you validate JWTs locally, also check iss, aud, exp and the header's typ: at+jwt.
How to test (sandbox), then go live
#Build and test against the sandbox tenant first: its own separate application with its own credentials (see the admin guide). A sandbox tenant follows exactly the same rules as a live one, except:
- Mail and SMS are captured instead of delivered. Your OneLogin admin sees every message, sign-in and verification codes included, under Admin → Sandbox mail, so you can test sign-up and sign-in with made-up addresses and numbers. Open the sandbox inbox link your admin gave you, or the Sandbox inbox page of the developer portal if your team has an account there, to read your own sandbox tenant's codes yourself (codes expire 10 minutes after they are sent and allow 5 attempts); with neither, ask your admin for the code.
- http://localhost and 127.0.0.1 redirect URIs are allowed (only on sandbox), and so are http:// redirect URIs on any .test or .localhost host (for example redeem.test). Prefer localhost or a .localhost host: a browser sends those to your own machine and nowhere else. A .test name is reserved and never in public DNS, but your own machine's DNS is what resolves it, so give it a hosts-file entry (for example 127.0.0.1 redeem.test); any other http:// host is refused.
- Every token and userinfo response carries env: "sandbox".
Redirect URIs and the inbox need not wait for an admin: a team with a developer-portal account can add sandbox redirect URIs and read the sandbox inbox itself; live redirect URIs are requested there and approved by an administrator. See the developer guide.
The Try it console above works with sandbox credentials and deliberately refuses live ones: run live requests from your own server, with the curl examples in this guide.
Going live is a configuration swap: the client ID, client secret and an https:// redirect URI (and the issuer, only if you tested against a different OneLogin instance). Same code, never a code change.
Social sign-in (Google / Facebook)
#Social sign-in needs nothing from a relying party: the "Continue with Google" / "Continue with Facebook" buttons live on OneLogin's own hosted login, and you receive exactly the same sub/claims regardless of which method the user signed in with. email_verified is true for a provider-verified address or one confirmed by our own emailed code — no separate handling needed on your side.