Sentroy Auth
Drop a "Sign in with Sentroy" button into your site. Standard OAuth 2.0 authorization-code flow + OIDC-compliant id_token — works with any spec-aware library, no custom integration code.
Overview#
Sentroy Auth is an OAuth 2.0 / OpenID Connect provider hosted at auth.sentroy.com. Users authenticate with their existing Sentroy account; your app gets back a verified profile (name, email) and a stateless id_token JWT.
Flow
1.User clicks "Sign in with Sentroy" on your site → redirected to https://auth.sentroy.com/oauth/authorize?...
2. If signed into Sentroy, consent screen appears immediately. If not, user logs in first (cross-subdomain cookie — single round-trip).
3. User clicks "Allow" → 302 back to your redirect_uri?code=...
4. Your backend exchanges the code at POST /oauth/token for an access_token + id_token.
5. Optional: call GET /oauth/userinfowith the access token for the user's profile.
Register an OAuth client#
Each site that signs users in needs a client_id + client_secret. One client per app, manage from your dashboard.
Open your Sentroy dashboard → company → OAuth clients → New OAuth client. You'll be asked for:
- Name — shown to users on the consent screen.
- Redirect URIs (one per line) — the URLs Sentroy is allowed to send the auth code back to. Add both your dev (
http://localhost:3000/...) and prod (https://app.example.com/...) URLs. - Homepage URL(optional) — shown on the consent screen as "learn more" link.
On submit you get a one-time view of client_secret. Copy it into your app's deploy env immediately — it cannot be shown again. If you lose it, rotate it from the dashboard.
Quickstart (NextAuth)#
Most OAuth libraries auto-configure from the discovery document — one URL covers it.
// auth.ts (NextAuth v5)
import NextAuth from "next-auth"
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
{
id: "sentroy",
name: "Sentroy",
type: "oidc",
issuer: "https://auth.sentroy.com",
clientId: process.env.SENTROY_CLIENT_ID!,
clientSecret: process.env.SENTROY_CLIENT_SECRET!,
authorization: { params: { scope: "openid profile email" } },
},
],
})That's it. NextAuth fetches https://auth.sentroy.com/.well-known/openid-configuration and learns every endpoint URL automatically. Sign-in button:
<form action={async () => {
"use server"
await signIn("sentroy", { redirectTo: "/dashboard" })
}}>
<button type="submit">Sign in with Sentroy</button>
</form>REST endpoints#
If you're rolling your own OAuth client (or your library doesn't read discovery), here are the raw endpoints.
GET /oauth/authorize
Required query: response_type=code, client_id, redirect_uri, scope (space-separated; must include openid).
Optional: state (CSRF token, echoed back), nonce (embedded in id_token).
https://auth.sentroy.com/oauth/authorize?
response_type=code&
client_id=client_abc123&
redirect_uri=https%3A%2F%2Fapp.example.com%2Fapi%2Fauth%2Fcallback&
scope=openid%20profile%20email&
state=<random>&
nonce=<random>POST /oauth/token
Exchange the authorization code for tokens. Client authentication via Basic header (preferred) or form fields.
curl -X POST https://auth.sentroy.com/oauth/token \
-u client_abc123:secret_xxx \
-d grant_type=authorization_code \
-d code=oac_xxxxxxxxxxxx \
-d redirect_uri=https://app.example.com/api/auth/callback{
"access_token": "oat_...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email",
"id_token": "eyJhbGciOiJIUzI1NiIs..."
}GET /oauth/userinfo
Fetch the user's profile (claims depend on granted scopes).
curl -H "Authorization: Bearer oat_..." \
https://auth.sentroy.com/oauth/userinfo{
"sub": "<user-id>",
"name": "Aras Yilmaz",
"preferred_username": "Aras Yilmaz",
"email": "aras@example.com",
"email_verified": true
}GET /.well-known/openid-configuration
Discovery document — OAuth/OIDC libraries fetch this once at init to learn all endpoint URLs and supported parameters.
Scopes#
Pick only what you need — users see them on the consent screen.
openid— required by the spec. Returnssub(Sentroy user id) only.profile— addsname,preferred_username,picture.email— addsemail+email_verified.offline_access— issues arefresh_tokenalongside the access / id token, so the user stays signed in past the 1-hour TTL. Must be on the client's allow-list before it can be requested.
Each OAuth client has an allow-list of scopes (set on registration). Authorize requests for scopes outside the allow-list are rejected.
PKCE (RFC 7636)#
Proof Key for Code Exchange — recommended for all clients, required for SPA / mobile.
Sentroy Auth supports PKCE with the S256 method. Use it whenever you can — it adds zero friction for users and closes the "intercepted authorization code" attack window. Most libraries (NextAuth, oauth4webapi, Authlib, etc.) enable PKCE automatically when they see it in the discovery document.
// 1. Generate a verifier + challenge before redirecting
import { createHash, randomBytes } from "crypto"
const verifier = randomBytes(32).toString("base64url")
const challenge = createHash("sha256").update(verifier).digest("base64url")
// 2. Store verifier in your session, send challenge to authorize:
// .../oauth/authorize?...&code_challenge=<challenge>&code_challenge_method=S256
// 3. On callback, send verifier to /oauth/token:
// grant_type=authorization_code&code=...&code_verifier=<verifier>Refresh tokens#
Keep users signed in past the 1-hour access token TTL — opt in via the offline_access scope.
Add offline_accessto the requested scopes (and to the client's allow-list in the dashboard). On consent the token endpoint returns a refresh_token alongside the access/id tokens. When the access token expires, exchange the refresh token for a new pair:
curl -X POST https://auth.sentroy.com/oauth/token \
-u client_abc123:secret_xxx \
-d grant_type=refresh_token \
-d refresh_token=ort_xxxxxxxxxxxxConsent reuse#
Returning users skip the consent screen for the same client + scopes.
The first time a user approves an OAuth client, Sentroy records which scopes they granted. Subsequent /oauth/authorize requests for the same (or narrower) scopes redirect straight back to the RP — no consent screen, single round-trip. This matches the behaviour of Google / GitHub / Apple sign-in.
To force the consent screen anyway (e.g. for security-sensitive flows), append prompt=consent to the authorize URL. Requesting a scope outside the previously granted set always re-prompts.
Signing keys (RS256 + JWKS)#
Public-key id_token signing — RPs verify locally without round-tripping to userinfo.
Set OAUTH_RSA_PRIVATE_KEY on the auth deploy to a PEM-encoded RSA private key. Sentroy switches id_token signing to RS256, publishes the public key at /.well-known/jwks.json, and advertises the JWKS URI in the discovery document. RP libraries auto-detect the new mode on next discovery refresh.
# Generate a fresh RSA key (one-time)
node -e "console.log(require('crypto').generateKeyPairSync('rsa',{modulusLength:2048}).privateKey.export({type:'pkcs8',format:'pem'}))" \
> oauth_rsa_private.pem
# Then set the contents as OAUTH_RSA_PRIVATE_KEY on the auth Coolify envThe kid is derived from the key (RFC 7638 JWK SHA-256 thumbprint) — no extra env required. WithoutOAUTH_RSA_PRIVATE_KEY, Sentroy falls back to HS256 (OAUTH_ID_TOKEN_SECRET); JWKS stays empty and discovery omits jwks_uri.
Zero-downtime rotation
Sentroy supports two simultaneous keys for graceful rotation — OAUTH_RSA_PRIVATE_KEY for signing, OAUTH_RSA_PRIVATE_KEY_PREVIOUS kept in JWKS for verification. RPs see both public keys, look up the right one by kid in each id_token header, and verify both old and new tokens during the grace window.
# Step 1: shift the current key to the PREVIOUS slot
# On the auth deploy's Coolify env, copy the value of
# OAUTH_RSA_PRIVATE_KEY into OAUTH_RSA_PRIVATE_KEY_PREVIOUS.
# Step 2: generate a fresh key, set as PRIMARY
node -e "console.log(require('crypto').generateKeyPairSync('rsa',{modulusLength:2048}).privateKey.export({type:'pkcs8',format:'pem'}))" \
> new_oauth_rsa_private.pem
# Set OAUTH_RSA_PRIVATE_KEY to the new key's contents.
# Step 3: deploy
# New id_tokens are signed with the new key (kid changes); existing
# tokens stay verifiable via the previous key in JWKS.
# Step 4: wait for the access_token TTL to elapse (60 min default + margin),
# then remove OAUTH_RSA_PRIVATE_KEY_PREVIOUS and redeploy.Token revocation (RFC 7009)#
POST /oauth/revoke — invalidate an access or refresh token from the RP side.
curl -X POST https://auth.sentroy.com/oauth/revoke \
-u client_abc123:secret_xxx \
-d token=oat_xxxxxxxxxxxx
# → 200 OK (always, even for unknown tokens — spec)Optional token_type_hint=access_token or token_type_hint=refresh_token shortcuts the lookup. Sentroy returns 200 unconditionally per RFC §2.2 — never reveals whether the token existed.
Token introspection (RFC 7662)#
POST /oauth/introspect — check whether a token is currently valid.
curl -X POST https://auth.sentroy.com/oauth/introspect \
-u client_abc123:secret_xxx \
-d token=oat_xxxxxxxxxxxx{
"active": true,
"scope": "openid profile email",
"client_id": "client_abc123",
"sub": "<user-id>",
"token_type": "Bearer",
"exp": 1733000000,
"iat": 1732996400
}A token introspected by a different client returns {"active": false} regardless of actual validity — strict client binding. Useful for stateless services that need a live-check without parsing the token themselves.
End session#
GET/POST /oauth/end-session — RP-initiated logout (OIDC).
Send the user here when they log out of your site. Sentroy revokes all access + refresh tokens issued to your client for that user, then redirects back to your post_logout_redirect_uri(must be on the client's allow-list — same security boundary as the authorize redirect).
https://auth.sentroy.com/oauth/end-session?
id_token_hint=eyJhbGciOiJSUzI1NiIs...&
post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2Floggedout&
state=<random>Connected apps (user-side)#
End users see + revoke their authorizations from their Sentroy profile.
Users visit https://sentroy.com/[lang]/profile/connected-apps to see every app they've signed into with their Sentroy account, what scopes they granted, and a one-click Revoke button. Revoke triggers a cascade:
- Consent record deleted → next authorize re-prompts.
- All access tokens for the (user, client) pair revoked →
/oauth/userinfo401s instantly. - All refresh tokens for the pair revoked → refresh attempts return
invalid_grant.
Security notes#
What v1 enforces, what to mind when integrating.
- state is your CSRF guard. Generate a random value before redirecting to authorize, store it in a session cookie, verify on the callback. Most libraries do this for you.
- nonce defends against id_token replay. Generate a random value, send in authorize, verify it matches
nonceclaim in the returned id_token. - PKCE is recommended for every client and required for any client without a confidential
client_secret(SPA, mobile, native). - id_tokenis signed HS256 with a Sentroy-side secret. The spec recommends RS256 + JWKS — that's on the roadmap. Today, treat the id_token as a userinfo shortcut, not a self-issued JWT you can hand to other services.
- access_token is opaque (not a JWT). Validate by calling
/oauth/userinfo— a 200 response means the token is live + the user still exists. - code lifetime is 10 minutes, single-use. Replays return
invalid_grant. - refresh_token lifetime is 30 days. Rotation on every use. Family revocation on replay.