Introduction
Your users already have a Corenexis Account. This lets them use it to sign in to your app — you never handle their password, and you get a verified identity back.
One integration covers every way in: password, magic link, Google, GitHub, Microsoft and Facebook, plus two-factor authentication, the consent screen and device management. You do not add providers one at a time, and you do not maintain them.
1. Create your app
Apps are created and managed on the identity platform itself — accounts.corenexis.com/developer. You need a Corenexis account with a verified email address and two-step verification turned on — an app owner can issue tokens for everyone who signs in to their app, so that account has to be the hardest one to steal. This site is documentation only; nothing is registered here.
| Field | Notes |
|---|---|
| Display name | What users see on the sign-in and consent screens. Use the real name of your app — it is the main thing people judge before approving. |
| Redirect URLs | Where we return the user. Prefix matched — register the full path, one per line. Building a mobile or desktop app? Register your own scheme (com.yourapp.name://auth/callback) and read §11. |
| Website URLrequired | Shown on the consent screen, used as the "back to your app" exit on our error pages, and where a cancelling user is sent if there is no usable callback. Point it at a real landing page, not a deep link. |
| App logorequired | Uploaded, not linked. PNG, JPG or WebP, up to 1 MB — drag it onto the field or pick a file. We re-encode it to a 256×256 WebP and serve it from Corenexis, so the consent screen never fetches an image from your server: it stays fast, and your logo host never learns who signs in to you. A wide mark is padded onto the square, never cropped. SVG is not accepted. |
| Privacy policy URLrequired | Shown to users on the consent screen when they approve your app. An app that asks strangers for their email and profile should be able to say what it does with them. |
| Terms of service URLoptional | Shown next to your privacy policy on the consent screen, with the line “By continuing you agree to <your app>'s terms of service and privacy policy”. Required for verification (§13). |
| Token lifetimes | Fixed for self-service apps: access token 15 minutes, refresh token 7 days. |
| Data access | Fixed starter set — see §12. |
403 or a 404 is fine,
we are only confirming a server is really there. A link to a domain that does not exist is worse
than no link, and these end up on the screen where a user decides whether to trust you.
http://localhost… is exempt while you develop.
You get back two values:
| Value | What it is |
|---|---|
client_id |
A generated, opaque identifier such as app_9f2c41ab7d05e8c3f1a6b204. It is public — it appears in redirect URLs and is safe to ship in config. You do not choose it, and users never see it; they see your display name. |
client_secret |
48 hex characters, shown exactly once. Only a bcrypt hash is stored, so it can never be recovered — if you lose it, rotate it from your app's page and redeploy. Treat it like a password: server-side only, never in a browser, mobile app or public repository. |
Store them in your app's environment:
CRX_BASE_URL=https://accounts.corenexis.com
CRX_CLIENT_ID=app_9f2c41ab7d05e8c3f1a6b204
CRX_CLIENT_SECRET=<the secret shown once>
CRX_CALLBACK_URL=https://yourapp.com/auth/callback
You can hold up to five apps per account, and you can suspend or delete any of them yourself.
2. Manage your app
Everything below is self-service from your app's page under Developer → Your apps. No ticket, no waiting.
| Action | What happens | When to use it |
|---|---|---|
| Edit | Name, logo, website and redirect URLs change immediately. Live sessions are unaffected. | Rebrand, new callback path, moving domains. |
| Rotate secret | A new secret is shown once; the old one stops working the moment you rotate. | The secret leaked, a developer left, or it is simply old. Deploy the new value first-thing after rotating. |
| Sign all users out | Every refresh token for your app is revoked. Access tokens keep verifying until they expire (they are self-contained JWTs). | Suspected compromise, or a breaking change to your session model. |
| Suspend | Sign-in redirects to your app are refused and every /api/token grant returns invalid_client. Fully reversible. | Taking the app offline for a while. |
| Delete | The app and all of its sessions are removed, and the client id can never be reused. | Permanent shutdown. Prefer suspending. |
3. The sign-in flow
Your app Corenexis Accounts Your server
│ │ │
1. └─ redirect user ────────────▶│ │
│ │ user signs in, approves │
2. │◀──── redirect back with ?code=… ──────────────────────▶ │
│ │ │
3. │ │◀── POST /api/token ────────┤
│ │ client_id + secret │
│ │──── access + refresh ─────▶│
│ │ token + user │
4. └─ your own session cookie ◀───────────────────────────────┘Steps 1 and 2 happen in the browser. Steps 3 and 4 are server-to-server — the code is useless to anyone who does not also hold your client secret.
4. Send the user to us
Redirect the browser to the Corenexis sign-in page with your callback URL, URL-encoded, in redirect:
https://accounts.corenexis.com/?redirect=https%3A%2F%2Fyourapp.com%2Fauth%2Fcallback
/login and /signup accept ?redirect= too if you would rather
land users on one of those directly. From here we handle everything: password, magic link, Google,
GitHub, Microsoft, two-factor — and, the first time, the consent screen showing your app's name,
logo and exactly what will be shared.
redirect value must start with one of your registered redirect URLs.
If it does not match we stop and show the user a 400 error card naming the problem.
We never sign someone in to an app we cannot identify, and we never quietly drop them on their
dashboard. This mismatch is the single most common integration mistake.
Send a state — strongly recommended
Generate an unguessable value per sign-in, keep it in your own session, and pass it alongside redirect. We return it unchanged on the callback:
https://accounts.corenexis.com/?redirect=https%3A%2F%2Fyourapp.com%2Fauth%2Fcallback&state=8f2b…
Refuse any callback whose state does not match the one you stored — that
is what stops someone feeding your app a callback it never asked for (RFC 6749 §10.12). It is opaque
to us: never parsed, never interpreted, only handed back. Maximum 512 characters; anything longer is
dropped rather than truncated. Omitting it changes nothing else about the flow.
Go straight to a provider — optional
If your own screen already has a Continue with Google button, add provider and we
skip our sign-in page and open Google's account picker directly:
https://accounts.corenexis.com/?redirect=https%3A%2F%2Fyourapp.com%2Fauth%2Fcallback&state=8f2b…&provider=google
Accepted values: google, github, microsoft, facebook.
It is honoured only together with a valid redirect and only while that provider is enabled;
otherwise the normal sign-in page is shown. The picker appears even if the browser is already signed in
to Corenexis, because the user asked to choose an account. Everything after that is unchanged: the
account is a Corenexis account, and consent, the code and your callback work exactly as below.
5. Handle the callback
After a successful sign-in we send the browser back to your callback with a code:
https://yourapp.com/auth/callback?code=3f9c…64-hex-chars…7b&state=8f2b…
If the user pressed Cancel on the consent screen you get an error instead — handle both:
https://yourapp.com/auth/callback?error=access_denied&state=8f2b…
Cancel: what your app must do
When you receive error=access_denied, show your signed-out page — a landing page, or a sign-in screen with a button the user has to press.
/api/me call returns 401, and the app redirects
into SSO again. The user is then straight back on the consent screen they just dismissed, with no way
out — and it reads as our bug, not yours. "The user declined" is a state your app should be
able to sit in, exactly like a sign-out.
Your policy links on the consent screen
If your app has a privacy policy URL or a terms URL, the consent screen shows them under your app's name and closes with “By continuing you agree to <your app>'s terms of service and privacy policy” — the same treatment Google and Microsoft give a client's own policies. Set neither and nothing is rendered: users are never shown a link that goes nowhere. Both live on your app's page and take effect immediately.
As a safety net we refuse to render the consent screen twice in a row for an app the user just declined: the second request inside a short window gets a dead-end page with links back to your app and to their account. That stops the loop, but it is a guard rail, not a substitute for handling the error.
The code is single use and expires in 60 seconds. Exchange it immediately, server-side.
6. Exchange the code for tokens
Server-to-server, within 60 seconds of receiving the code. Pick your language — the choice sticks for every sample on this page.
curl -X POST https://accounts.corenexis.com/api/token \
-d grant_type=authorization_code \
-d code="$CODE" \
-d client_id="$CRX_CLIENT_ID" \
-d client_secret="$CRX_CLIENT_SECRET"
// Express callback route
app.get('/auth/callback', async (req, res) => {
if (req.query.error) return res.redirect('/?signin=cancelled');
if (req.query.state !== req.session.oauthState) return res.status(400).send('bad state');
const r = await fetch(process.env.CRX_BASE_URL + '/api/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: req.query.code,
client_id: process.env.CRX_CLIENT_ID,
client_secret: process.env.CRX_CLIENT_SECRET,
}),
});
const tok = await r.json();
if (!r.ok) throw new Error(tok.error_description || tok.error);
// Pin your session to the returned account — see the warning in §7.
req.session.userId = tok.user.id;
req.session.refreshToken = tok.refresh_token;
res.redirect('/');
});
<?php
// /auth/callback.php
session_start();
if (isset($_GET['error'])) { header('Location: /?signin=cancelled'); exit; }
if (($_GET['state'] ?? '') !== ($_SESSION['oauth_state'] ?? '')) { http_response_code(400); exit('bad state'); }
$ch = curl_init(getenv('CRX_BASE_URL') . '/api/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'authorization_code',
'code' => $_GET['code'],
'client_id' => getenv('CRX_CLIENT_ID'),
'client_secret' => getenv('CRX_CLIENT_SECRET'),
]),
]);
$tok = json_decode(curl_exec($ch), true);
curl_close($ch);
if (!empty($tok['error'])) { throw new RuntimeException($tok['error_description']); }
$_SESSION['user_id'] = $tok['user']['id']; // pin to this account
$_SESSION['refresh'] = $tok['refresh_token'];
header('Location: /');
# Flask callback route
import os, requests
from flask import request, session, redirect
@app.get("/auth/callback")
def callback():
if request.args.get("error"):
return redirect("/?signin=cancelled")
if request.args.get("state") != session.get("oauth_state"):
return "bad state", 400
r = requests.post(
os.environ["CRX_BASE_URL"] + "/api/token",
data={
"grant_type": "authorization_code",
"code": request.args["code"],
"client_id": os.environ["CRX_CLIENT_ID"],
"client_secret": os.environ["CRX_CLIENT_SECRET"],
},
timeout=10,
)
tok = r.json()
r.raise_for_status()
session["user_id"] = tok["user"]["id"] # pin to this account
session["refresh"] = tok["refresh_token"]
return redirect("/")
HTTP Basic works too — Authorization: Basic base64(client_id:client_secret) instead of the two body fields.
You may also send redirect_uri with the exchange (RFC 6749 §4.1.3). If you do, it must be
exactly the redirect the code was issued for, or the exchange fails with
invalid_grant. Leaving it out is fine.
What comes back
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6…",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "a1b2c3…",
"refresh_expires_in": 604800,
"user": {
"id": "9c1f8e42-5b7a-4d3e-9a11-72c4e8f0b5d6",
"email": "[email protected]",
"email_verified": true,
"name": "Someone",
"avatar_url": "https://accounts.corenexis.com/uploads/avatars/abc123.webp",
"country": "IN",
"timezone": "Asia/Kolkata"
}
}
7. What your app receives
An app only ever receives the fields it has been granted. Everything else is
stripped from both /api/token and /api/userinfo — there is no
way to ask for more at request time, and a user is never shown a permission your app cannot use.
| Field | Type | Notes |
|---|---|---|
id | uuid | The stable identifier. Key everything on this. |
email | string | Can change — never use it as a primary key. |
email_verified | bool | Check it before trusting the address. |
name | string · nullable | Display name. |
avatar_url | string · nullable | Append ?s=<width> for a resized copy. null if the user has set no picture. |
country / timezone | string · nullable | ISO-3166 alpha-2 and an IANA zone. |
phone_country_code / phone_number | string · nullable | Only with the phone grant (§12). |
totp_enabled / created_at | bool · ISO 8601 | Only with the security grant. |
null.
Write your code against the list on your app's page.
user.id. Skip this and someone who switches accounts stays
logged in as the previous one.
8. Verifying the access token
The access token is an RS256 JWT. You do not have to verify it for a plain login — it came straight from us over TLS. Verify when your API receives the token from somewhere else.
Public keys: https://accounts.corenexis.com/.well-known/jwks.json
Discovery: https://accounts.corenexis.com/.well-known/openid-configuration
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL(process.env.CRX_BASE_URL + '/.well-known/jwks.json'));
const { payload } = await jwtVerify(accessToken, JWKS, {
issuer: process.env.CRX_BASE_URL,
audience: process.env.CRX_CLIENT_ID,
});
// payload.sub === the user's id
Check iss, aud and expiry. Cache the JWKS — keys rotate rarely, and each key carries a kid.
9. Refreshing and signing out
Refresh. Refresh tokens rotate: every refresh returns a new one and retires the old. Always store the newest.
POST https://accounts.corenexis.com/api/token
grant_type=refresh_token
&refresh_token=<current refresh token>
&client_id=<your client_id>
&client_secret=<your client_secret>
Sign out. Revoke the refresh token when the user logs out of your app.
POST https://accounts.corenexis.com/api/token
grant_type=revoke
&refresh_token=<current refresh token>
&client_id=<your client_id>
&client_secret=<your client_secret>
10. Fetching the profile later
GET https://accounts.corenexis.com/api/userinfo
Authorization: Bearer <access_token>
Returns the same user object, fresh. Use it when you want current details rather than the copy from sign-in time.
11. Android, iOS and desktop apps
A native app has no https address to come back to, so register your own scheme
as the redirect URL and the operating system hands the callback to your app.
com.yourapp.name://auth/callback
Open the sign-in URL in the system browser (or a Custom Tab / ASWebAuthenticationSession), with your scheme URL-encoded:
https://accounts.corenexis.com/?redirect=com.yourapp.name%3A%2F%2Fauth%2Fcallback
&state=<random>&code_challenge=<challenge>&code_challenge_method=S256
The user signs in and the OS routes this straight back into your app:
com.yourapp.name://auth/callback?code=<authorization_code>&state=<random>
Android — the two pieces of wiring
Declare the scheme on the activity that should receive the callback:
<!-- AndroidManifest.xml -->
<activity android:name=".AuthCallbackActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.yourapp.name" android:host="auth" />
</intent-filter>
</activity>
Open sign-in in a Custom Tab — never a WebView, which hides the address bar the user needs to trust the page:
// Kotlin
val verifier = randomVerifier() // 43–128 chars, A-Z a-z 0-9 - . _ ~
val challenge = base64UrlNoPad(sha256(verifier)) // S256
store(verifier) // survives the app being backgrounded
val url = Uri.parse("https://accounts.corenexis.com/")
.buildUpon()
.appendQueryParameter("redirect", "com.yourapp.name://auth/callback")
.appendQueryParameter("state", state)
.appendQueryParameter("code_challenge", challenge)
.appendQueryParameter("code_challenge_method", "S256")
.build()
CustomTabsIntent.Builder().build().launchUrl(context, url)
// AuthCallbackActivity.onCreate — the OS delivers the code here
val code = intent?.data?.getQueryParameter("code")
// POST { code, code_verifier } to YOUR backend, which does the real exchange.
code. That code is harmless on its own — the exchange in §6 still requires the secret — so
send it to your own server and exchange it there. Put the secret in the app and an attacker who
catches a code has both halves.
your app ──code + verifier──▶ your backend ──code + verifier + client_secret──▶ /api/token
│
└──▶ returns your own session token to the app
A desktop app can instead open a short-lived local listener and register
http://127.0.0.1:<port>/callback — that is accepted too. Either way a bare scheme
(com.yourapp.name://) is rejected: redirect URLs are prefix-matched, so it would hand the
entire scheme to one app.
https callback hands the result on to your app
(https://yourapp.com/auth/callback → 302 → com.yourapp.name://auth?…): register
com.yourapp.name://auth as a redirect URL too. Otherwise a new user's first sign-in stops on the
consent screen, while returning users (who skip consent) work fine — a confusing bug to chase.
Corenexis's own apps can additionally sign users in with the phone's Google account sheet, without a browser. That grant is limited to first-party apps and is not available to self-service apps; the flow on this page is the one to build.
PKCE — supported, and recommended here
PKCE (RFC 7636) ties the code to a secret your app generates fresh for each sign-in, so a code caught by another app on the device is useless on its own. It is optional: send a challenge and we require the verifier; send nothing and the flow is unchanged.
code_challenge = BASE64URL( SHA256( code_verifier ) )
code_challenge_method = S256
Then send the verifier with the exchange in §6:
grant_type=authorization_code&code=…&client_id=…&client_secret=…&code_verifier=<verifier>
Only S256 is accepted — plain defeats the point. PKCE does not
replace the client secret here: the exchange still needs it, so keep doing it from your backend. PKCE is
the layer on top that makes a custom-scheme callback safe.
12. Scopes and asking for more
Every self-service app starts with the same set, and it is enforced on the server after the request is parsed — a hand-crafted request cannot widen it.
| Grant | Fields | New apps |
|---|---|---|
| Account ID | id | Always — not a scope, it is the subject identifier |
| Profile | name, avatar_url | Included |
email, email_verified | Included | |
| Locale | country, timezone | Included |
| Phone | phone_country_code, phone_number | On request |
| Security | totp_enabled, created_at | On request |
Requesting an increase
Phone and security are not self-service — an app has to justify them. Write to us from the email address that owns the app, through the Corenexis helpdesk, with:
- your
client_idand the app's display name; - which extra grant you need, and the feature that needs it — "for completeness" is not a reason;
- where the data will be stored, and for how long;
- a link to your privacy policy.
If it is approved, the change takes effect on the account without a redeploy. Users who already approved your app are asked again for the new permission the next time they sign in — a scope you were granted later is never handed over silently.
13. Getting your app verified
By default a self-service app is labelled Third-party app — not built or operated by Corenexis on the consent screen, and shows as Third-party on the user's Connected apps page. That label is honest, and for most apps it is fine.
A verified app drops the third-party warning and carries a Verified badge instead. Verification is presentation only — it does not change your scopes, tokens or sessions.
To apply, contact us through the helpdesk with:
- your
client_idand display name; - the live URL where the app runs, reachable without signing in;
- a privacy policy URL and a terms of service URL, both set on the app and both reachable on that domain;
- proof you control the domain (a DNS TXT record or a file we name);
- a working sign-in we can complete end to end.
14. Errors
Before the user reaches your callback
Some requests are stopped at Corenexis. The user sees an error page on
accounts.corenexis.com, the flow ends there, and nothing is sent to your callback
— no code, no error parameter. That is deliberate: an auth error must never be forwarded to a
URL we could not verify.
| What the user sees | Why | Fix |
|---|---|---|
| "We don't recognise that app" | Your redirect does not prefix-match any URL registered for your app, or the app is suspended | Compare the exact URL you send with what is on your app's page. By far the most common mistake. |
| "This sign-in request has expired" | They landed on the consent screen with no pending request — usually a bookmarked or stale URL | Always start the flow from your app; never link users straight to /consent |
| "This sign-in request has already been used" | The consent form was submitted twice — back button, double click, or a retry after your callback failed to load | Their approval is already saved. Send them through sign-in again; they will not be asked twice. |
https://app.example.com/auth/callback does
not match a registration of https://app.example.com/callback, and a trailing slash
counts. If sign-in dies at the very first hop, check this before anything else.
At the token endpoint
Token errors are standard OAuth 2.0 JSON with the matching HTTP status:
{ "error": "invalid_grant", "error_description": "Code is invalid, expired or already used." }
| Error | Meaning | What to do |
|---|---|---|
invalid_client | Unknown client_id, wrong secret, or the app is suspended | Check both values, and that the app is active on its page |
invalid_grant | Code expired (60s), already used, issued to another client, or the refresh token was revoked | Restart the sign-in flow |
invalid_grant (retired token) | A refresh token that had already been rotated away was presented — treated as replay, so the session was revoked | Sign the user in again, then fix the retry/storage bug that resent it |
invalid_grant (code_verifier) | PKCE was used at sign-in but the verifier was missing or did not match | Send the same code_verifier you derived the challenge from |
invalid_grant (redirect_uri) | You sent a redirect_uri that differs from the one the code was issued for | Send the exact same value, or leave the field out |
unsupported_grant_type | Not one of the three grants | Use authorization_code, refresh_token or revoke |
slow_down | Rate limited | Back off and retry |
access_denied | The user pressed Cancel (arrives on your callback, not here) | Show a friendly "sign-in cancelled" — and do not re-enter SSO |
15. Security rules
- Register full callback paths. Redirect URLs are prefix-matched, so registering
https://yourapp.commakes every page on your domain a valid callback — any one of them could capture auth codes. - Never ship the client secret to a browser or mobile app. There is no public-client flow here; if you cannot keep a secret, you need a small backend.
- Exchange the code immediately, server-side. 60 seconds, one use.
- Send a
stateand verify it — reject any callback whose state does not match the one you stored. - Handle
error=access_deniedwithout restarting sign-in. See §5. - Pin the session to
user.idafter every exchange. - Never key records on the email address. It can change;
idcannot. - Store refresh tokens encrypted at rest, and always replace them after a refresh.
- Rotate the secret immediately if it ever appears in a log, a repository or a screenshot.
- We never send you the user's password, 2FA codes or backup codes. Nothing you receive can be used to sign in as them anywhere else.
Before you go live
- Callback registered as a full path, and the
redirectyou send starts with it exactly. - Website URL, app logo and privacy policy set — all three appear on the consent screen, and users decide whether to trust you from them.
- You send
stateand reject a mismatch. - Your callback handles three outcomes:
code,error=access_denied, and neither (someone opened the URL directly). - Cancel leaves the user on your signed-out page — no automatic re-entry into SSO.
- The code is exchanged server-side within 60 seconds, and the secret is not in your client bundle.
- Your records are keyed on
user.id, never the email address. - Native or desktop app? You are using PKCE and exchanging through your own backend — see §11.
- Web callback that hands off to your app? The app URL is registered too, and you have tested a brand-new account through the consent screen — see §11.
- You have tested a second sign-in: consent must not appear again, and a refresh must return a new refresh token that you store in place of the old one.
16. Endpoint reference
| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
/?redirect=… | GET | — | Start sign-in (browser) |
/api/token | POST | client_id + secret | Code exchange, refresh, revoke |
/api/userinfo | GET | Bearer access token | Current profile |
/.well-known/jwks.json | GET | — | Public keys for JWT verification |
/.well-known/openid-configuration | GET | — | Discovery document |
All of them live under https://accounts.corenexis.com.
17. FAQ
- Can I choose my own client ID?
- No. Self-service apps get a generated opaque ID so no app can impersonate a first-party Corenexis product. Users identify your app by its display name and logo, not its ID.
- Will my app show up on other people's Corenexis dashboards?
- No. Apps you create are private — reachable only through the sign-in link you send users, and clearly marked Third-party on the consent screen until they are verified.
- I lost the client secret.
- It cannot be recovered — only a bcrypt hash is stored. Rotate it from your app's page and redeploy with the new value. The old secret stops working the moment you rotate.
- How do I test without a second account?
- Corenexis supports several accounts in one browser: add another from the account menu and switch between them. That is also the fastest way to check your account pinning is right.
- Do you support OpenID Connect discovery?
- Yes —
/.well-known/openid-configurationand a JWKS endpoint. Access tokens are RS256 JWTs, so a standard OAuth 2.0 client library is all you need. - What happens when I suspend my app?
- Sign-in redirects to it are refused and every
/api/tokengrant returnsinvalid_client. Existing access tokens still verify until they expire, but nothing new can be issued. Fully reversible.
Create an app, copy the two values into your environment, and you can have sign-in working today.
Create an app