Auth
Hosted sign-in on your brand — one-time email codes, Google and LinkedIn. A real OIDC provider using PKCE, so there is no client secret.
What it is
Smart Services is an OpenID Connect provider: your app hands sign-in over to a hosted, branded page, and gets back a signed ID token describing the user. Your app is a public OIDC client using PKCE, so there is no client secret to store or rotate.
Before you start
Two values, neither of them secret, both from the Smart Services console → your app → Settings:
| Value | What it is |
|---|---|
client_id | Your app's id. It is also the aud of every token we mint for you. |
| Issuer | https://smart-services.io |
Register your redirect URI on the same page. It is an exact-match allowlist — no wildcards, though a trailing slash is ignored.
Discovery lives at https://smart-services.io/.well-known/openid-configuration. Point your OIDC library at it and it will configure itself:
{
"issuer": "https://smart-services.io",
"authorization_endpoint": "https://smart-services.io/authorize",
"token_endpoint": "https://smart-services.io/oauth/token",
"userinfo_endpoint": "https://smart-services.io/oauth/userinfo",
"end_session_endpoint": "https://smart-services.io/oauth/logout",
"jwks_uri": "https://smart-services.io/.well-known/jwks.json",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "email", "profile"],
"token_endpoint_auth_methods_supported": ["none"],
"code_challenge_methods_supported": ["S256"],
"claims_supported": ["sub", "email", "email_verified", "name", "picture"]
}
token_endpoint_auth_methods_supported: ["none"] is the important line. PKCE with S256 is the only client authentication. plain is rejected.
Your app API key is a different credential and is never part of sign-in. It authenticates server-to-server calls — the /api/v1/auth/* endpoints below, plus email, jobs, storage and the rest. Both header forms work:
Authorization: Bearer <app key>
x-api-key: <app key>
The key is shown in the console → your app → Settings. Never send it as client_secret: it authenticates every call your app makes, so putting it in a browser-initiated redirect flow turns a sign-in leak into full compromise. The token endpoint ignores client_secret entirely, so a literal 'unused' placeholder is safer — it grants nothing.
Your first call
Confirm the provider is reachable and advertising what your library needs:
curl -s https://smart-services.io/.well-known/openid-configuration \
| jq '{token_endpoint_auth_methods_supported, code_challenge_methods_supported, id_token_signing_alg_values_supported}'
const res = await fetch(
'https://smart-services.io/.well-known/openid-configuration'
);
const config = await res.json();
console.log(config.token_endpoint_auth_methods_supported);
Response:
{
"token_endpoint_auth_methods_supported": ["none"],
"code_challenge_methods_supported": ["S256"],
"id_token_signing_alg_values_supported": ["RS256"]
}
If you get HTML or a 404, check you are on smart-services.io — the old app.smart-services.io host was retired.
The NextAuth / Auth.js v5 provider block
// src/lib/auth.ts
import NextAuth from 'next-auth';
const issuer = process.env.SMART_SERVICES_AUTH_ISSUER!; // https://smart-services.io
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
{
id: 'smart-services',
name: 'Smart Services',
type: 'oidc',
issuer,
wellKnown: `${issuer}/.well-known/openid-configuration`,
clientId: process.env.SMART_SERVICES_AUTH_CLIENT_ID!,
// Auth.js v5's OIDCConfig type requires this field. It is never sent.
clientSecret: 'unused',
client: { token_endpoint_auth_method: 'none' },
authorization: { params: { scope: 'openid email profile' } },
checks: ['pkce', 'state'],
idToken: true,
profile: (p) => ({
id: p.sub,
email: p.email,
name: p.name ?? null,
image: p.picture ?? null,
}),
},
],
session: { strategy: 'jwt', maxAge: 30 * 24 * 60 * 60 },
});
Then export const { GET, POST } = handlers; in app/api/auth/[...nextauth]/route.ts, and register https://yourdomain.com/api/auth/callback/smart-services as your redirect URI. The last path segment is your provider id — change one and you must change the other.
On v4, use type: 'oauth' and omit clientSecret entirely; v4's types allow it. With better-auth, use genericOAuth with pkce: true and no clientSecret field.
What comes back
The token endpoint returns:
{
"access_token": "eyJ...",
"id_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600
}
ID token claims: iss, sub, aud, iat, exp, email, email_verified, name, picture, and nonce when you sent one. email_verified is always true — the one-time code went to that inbox, which is the verification.
The rest of the endpoints
OIDC endpoints. No app key; the flow authenticates itself.
| Method | Path | What it does |
|---|---|---|
| GET | /.well-known/openid-configuration | Discovery document. |
| GET | /.well-known/jwks.json | Public keys. Verifies ID tokens and every webhook we send you. |
| GET | /authorize | Hosted branded sign-in page. Start the flow here. |
| POST | /oauth/token | Exchange an authorization code for tokens. PKCE only. |
| GET, POST | /oauth/userinfo | Fresh claims for a Bearer access token. |
| GET, POST | /oauth/logout | RP-initiated logout. Burns anything in flight for that user. |
Server-to-server. App API key required, and the auth service must be enabled on the app.
| Method | Path | What it does |
|---|---|---|
| POST | /api/v1/auth/introspect | Verify a token server-side and get the user back. No OIDC library needed. |
| POST | /api/v1/auth/email-change | Start an email change for one of your users; returns a hosted confirmUrl. |
| POST | /api/v1/auth/email-change/revert | Put a user's address back, by appUserId or email. |
| POST | /api/v1/auth/test-user | Create an ephemeral test user for automated sign-in. |
| DELETE | /api/v1/auth/test-user | Remove it again. |
Browser-callable, no key (CORS is open; abuse is bounded by per-app, per-email rate limits):
| Method | Path | What it does |
|---|---|---|
| POST | /api/v1/auth/otp/request | Email a one-time code. Takes clientId, email. |
| POST | /api/v1/auth/otp/verify | Exchange the code for an authorization code. PKCE S256 required. |
| POST | /api/v1/auth/email-change/verify | Confirm an email change with the emailed code. |
/api/v1/auth/introspect is RFC 7662-shaped: an invalid token is {"active": false} with a 200, not an error. It returns active, sub, aud, exp and a user object of id, email, name, image. Tokens are app-scoped, so you can only introspect tokens minted for your own client_id.
Required: your auth-events endpoint
Every app using Smart Services sign-in must implement this. An integration without it is incomplete, and POST /api/v1/auth/email-change returns 400 rather than run a flow nothing is listening to.
It is one endpoint. Every server-to-server auth notification we will ever send arrives there, told apart by the type claim. New event types land on the same URL; you never add a second receiver.
Register it by passing authEventsUrl on your first POST /api/v1/auth/email-change call, or set it in the app's auth settings. The URL must be on an origin you have already registered as a redirect URI. That lock is deliberate: without it, a leaked app key could repoint your auth events at someone else's server and silence the one notification that tells you to kill a session.
The wire contract
POST <your authEventsUrl>
Authorization: Bearer <RS256 JWT>
Content-Type: application/json
The event is in the JWT claims, not the body. The body mirrors them for your logs and is not authoritative.
| Claim | Value |
|---|---|
iss | https://smart-services.io |
aud | your client_id — a token minted for another app cannot be replayed at yours |
sub | the AppUser.id; the same sub you stored at sign-in |
jti | stable across retries of one logical event, so you can dedupe |
iat, exp | issued-at and expiry (5 minutes) |
type | email_change.completed or email_change.contested |
data | newEmail, previousEmail and/or reason, depending on the event |
There is no webhook secret, and there never will be. Every webhook is signed with the same keypair that signs your ID tokens, published at {issuer}/.well-known/jwks.json. You verify with the JWKS your OIDC library already fetches at sign-in. If a future integration doc asks you for a webhook secret, it is wrong — raise it rather than provisioning one.
// app/api/auth-events/route.ts
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL(`${process.env.SMART_SERVICES_AUTH_ISSUER}/.well-known/jwks.json`)
);
export async function POST(req: Request) {
const token = (req.headers.get('authorization') ?? '')
.match(/^Bearer\s+(.+)$/i)?.[1];
if (!token) return new Response('unauthorized', { status: 401 });
let claims: any;
try {
({ payload: claims } = await jwtVerify(token, JWKS, {
issuer: process.env.SMART_SERVICES_AUTH_ISSUER!,
audience: process.env.SMART_SERVICES_AUTH_CLIENT_ID!,
}));
} catch {
return new Response('bad token', { status: 401 });
}
const { type, sub, data } = claims;
// `sub` never changes on an email change. Match on it, never on email.
const user = await db.user.findUnique({ where: { authSubject: sub } });
if (!user) return Response.json({ ok: true, applied: false });
switch (type) {
case 'email_change.completed':
// The user drove this themselves. Update the cached address.
await db.user.update({
where: { id: user.id },
data: { email: data.newEmail },
});
break;
case 'email_change.contested':
// Someone vetoed the change from the old address. `previousEmail` is the
// address the account is on NOW — absent when it could not be restored.
await db.user.update({
where: { id: user.id },
data: {
...(data?.previousEmail ? { email: data.previousEmail } : {}),
sessionsValidFrom: new Date(), // THE POINT — see below
lockedReason: data?.reason,
},
});
if (data?.reason === 'email-change-contested-unrecoverable') {
await alertSupport(user.id); // the address could not be given back
}
break;
default:
break; // ack unknown types so future events don't retry
}
return Response.json({ ok: true });
}
Why signing the user out is the whole point
The attack: someone uses a machine where your user is still signed in, and changes the account's email to their own. We mail the old address a decline link, so the real owner can undo it. But Smart Services cannot revoke your session cookie, and the access token (1 hour, no refresh) still carries the old email.
Skip the session invalidation and the owner reverts the address while the attacker keeps a live session in your app. You recovered the address and not the account. This event is the only signal you get.
Rules
- Verify the JWT (
iss,aud,exp) before acting, and read the event from its claims, not the body. An unverified request is an attacker asserting that a takeover was undone. - Idempotent, at-least-once. We try 3 times with a short backoff and a 5s timeout, then give up. The same event may arrive twice;
jtiis stable across retries. - Return 2xx fast, do work async. An auth operation never fails because your endpoint is down.
- Ack unknown event types with 2xx so future events don't retry against you.
Starting an email change
const res = await fetch('https://smart-services.io/api/v1/auth/email-change', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.SMART_SERVICES_API_KEY}`,
},
body: JSON.stringify({
email: user.email, // their CURRENT address
newEmail,
authEventsUrl: 'https://you.example/api/auth-events',
returnUrl: 'https://you.example/settings?email-changed=1',
// Where a DECLINE lands. Someone who clicks "this wasn't me" has just
// reported a possible takeover; the settings page is the wrong destination.
declineReturnUrl: 'https://you.example/security',
}),
});
const { confirmUrl, reference, appUserId, expiresAt } = await res.json();
redirect(confirmUrl); // branded confirm page, hosted by us
returnUrl and declineReturnUrl are matched by origin against your registered redirect URIs, and rejected with a 400 if they miss. The user lands back with ?email_change=completed — that is for UX only. Write your database from the webhook: the tab may be closed, and a decline never passes through your app at all.
Limits and errors
OIDC errors follow the spec: {"error": "invalid_grant"} or {"error": "invalid_client", "error_description": "..."}. Unknown client_id is 401 invalid_client; a bad code, a redirect_uri mismatch or a failed PKCE check is 400 invalid_grant. There is no request shape that redeems a code without a code_verifier — PKCE fails closed on a missing challenge.
The /api/v1/* endpoints return {"error": "..."} with the status in the HTTP code:
| Status | Meaning |
|---|---|
| 400 | Missing or malformed field; a returnUrl or authEventsUrl off your registered origins. |
| 401 | Missing or invalid app API key. |
| 403 | The auth service is not enabled for this app, or hosted auth is not configured. |
| 404 | No such user for this app. |
OTP requests are rate-limited per app and per email address inside the provider; the response is deliberately vague ("If this address can sign in, a code has been sent.") so it cannot be used to probe which addresses exist.
Auth is billed by live users — non-test accounts — not by a monthly meter, so nothing resets. At the ceiling, new sign-ups are refused and existing users keep signing in: hitting a billing limit must never lock your whole user base out. Which tier your app is on, and the current quota for it, are shown in the console on your app's page.
Gotchas
The issuer is smart-services.io. The old app.smart-services.io host was retired on 2026-09-21 and 404s every OIDC path. A venture still configured with it will look like a broken provider; it is a dead hostname.
sub is per-app, not portfolio-wide. It is the AppUser.id. The same human signing into two of your apps has two different sub values. email is the only thing that correlates across apps.
Store sub in its own column and match on it first, email second. Email is mutable; a user who changes theirs upstream must not become a second row. Keep a unique index on both. A signIn callback that overwrites user.id with your local id works, but leaves the sub stored nowhere — so there is no way to re-link after an email change. Don't do that.
There is no refresh token. The access token lasts 1 hour and is not renewed. Your own session cookie is what keeps a user signed in. Don't build refresh logic; there is nothing to refresh against.
Redirect URI mismatch is the most common failure. The last segment of the NextAuth callback path is your provider id. Register /api/auth/callback/smart-services for id: 'smart-services'. First thing to check when a callback fails.
Post-logout URIs match by origin, not exact path. That is deliberate: a landing page is not a code-bearing callback. Sign-in redirect URIs are still exact-match.
Logout does not clear a Smart Services session, and doesn't need to. The provider is stateless and sets no cookies, so clearing your own session cookie is sign-out. Calling /oauth/logout is optional, but it does real work when you pass id_token_hint: it burns any unconsumed authorization codes and OTPs for that user, so nothing in flight can be redeemed after sign-out.
No cross-app SSO. One issuer, but aud separates apps and sessions are per-app cookies. Signing into one app does not sign you into another. By design.
node:crypto imported in auth.ts breaks the client bundle. That file gets pulled into client-side bundles and webpack resolves the import statically. Keep node-only imports out of it.
Deriving secure cookies from NODE_ENV breaks under local TLS proxies. Derive secure from the actual request protocol instead.
Auth.js v5 error redirects behind a reverse proxy. v5 builds its error-redirect URL from the raw request, which behind a proxy can be the internal address. Set AUTH_URL explicitly and make sure the proxy forwards X-Forwarded-Proto.
Local dev: use a non-resolvable issuer for your bypass. https://local.smart-services.invalid (RFC 2606) can never validate against production even if a dev token leaks. Gate the bypass on an explicit env flag, never on NODE_ENV alone.
Don't reach for NODE_TLS_REJECT_UNAUTHORIZED=0 when a local TLS proxy upsets the discovery fetch. It disables verification for the whole process. Point NODE_EXTRA_CA_CERTS at your proxy's root certificate instead.