Browser sessions use httpOnly cookies with credentials: 'include' - never store JWT or access tokens in localStorage/sessionStorage for LoginMe browser auth. Prefer the LoginMe widget for embeds and social login; mint short-lived assertions via POST /v1/session/assertion only for your customer backend to verify.
Before using these examples, you need:
API Key - Get it from your organization settings in the dashboard
4f7f5937-ad07-4dd3-8860-97dc16db66bf
January 4, 2026
⚠️API keys are shown once at creation - store them securely (secrets manager / env). Session auth uses httpOnly cookies, not localStorage JWTs.
Navigate to your Dashboard → Organization Settings to find your API key
If you call the API from a different origin than the backend (e.g. your app at https://app.example.com calling https://api.loginme.io), and you are not using the LoginMe widget:
Origin in Access-Control-Allow-Origin (it does not use *), so credentialed requests work from any domain.fetch / XHR.credentials: 'include' in fetch. The response will include Access-Control-Allow-Origin: <your origin> and Access-Control-Allow-Credentials: true./v1/session/* and /v1/widget/* require your app Origin to be allowlisted for the tenant. Prefer socialPopup: true for social login to avoid third-party cookie issues on customer origins.For more detail and code examples, see API_EXAMPLES.md in the repo (section: Cross-origin (CORS) and credentials). Running your own LoginMe backend? Ensure CORS reflects the request origin for /api/v1/* and never returns * for routes called with credentials.
Recommended path for embeds and social login. The widget establishes an httpOnly cookie session - no JWT is exposed to your page JavaScript. Load the script from https://app.loginme.io/loginme-widget.js.
<div id="loginme-root"></div>
<script
src="https://app.loginme.io/loginme-widget.js"
loginme-key="your-api-key-here"
data-container="#loginme-root"
></script>Requires data-container pointing at a mount element. The widget renders there and manages the httpOnly session for you.
Alternative: Custom Element
<script src="https://app.loginme.io/loginme-widget.js"></script>
<loginme-widget loginme-key="your-api-key-here"></loginme-widget>Renders into the host element. Session storage is always httpOnly cookies (no localStorage).
<script src="https://app.loginme.io/loginme-widget.js"></script>
<div id="login-container"></div>
<script>
const widget = LoginMe.init({
apiKey: 'your-api-key-here',
mode: 'login', // or 'register'
theme: 'light', // or 'dark'
socialPopup: true, // preferred: OAuth in popup via /widget/callback
onLogin: (result) => {
// console.log('User logged in:', result.user); // Auto-commented by CI
// Session is httpOnly cookie; call your APIs with credentials: 'include'
window.location.href = '/dashboard';
},
onError: (error) => {
// console.error('Login error:', error); // Auto-commented by CI
}
});
widget.render('#login-container');
</script>Social buttons call POST /v1/widget/auth/start. After OAuth, the popup exchanges via POST /v1/widget/auth/callback/exchange(same-site session + CSRF), then postMessages identity to your opener. Prefer popup over redirect to avoid third-party cookie blocks.
<script src="https://app.loginme.io/loginme-widget.js"></script>
<script>
LoginMe.init({ apiKey: 'your-api-key-here', socialPopup: true });
LoginMe.login('user@example.com', 'password')
.then(result => {
// console.log('Logged in:', result.user); // Auto-commented by CI
// Session is httpOnly cookie; no token exposed to JS.
})
.catch(error => {
// console.error('Login failed:', error); // Auto-commented by CI
});
LoginMe.getCurrentUser()
.then(user => {
// console.log('Current user:', user); // Auto-commented by CI
});
LoginMe.logout()
.then(() => {
// console.log('Logged out'); // Auto-commented by CI
});
</script>socialPopup: trueLoginMe.init({
apiKey: 'your-api-key', // Required
apiUrl: 'https://api.loginme.io', // Optional
mode: 'login', // 'login' or 'register'
theme: 'light', // 'light' or 'dark'
socialPopup: true, // Recommended for social OAuth
widgetCallbackUrl: 'https://loginme.io/widget/callback',
onLogin: (result) => { /* result.user */ },
onError: (error) => { /* ... */ },
});Note: For detailed widget documentation, see the Widget Integration Guide.
// Configuration - store the API key securely (env / secrets), not as a JWT
const API_URL = 'https://api.loginme.io';
const API_KEY = 'ak_your_api_key_here';
function getApiKey() {
return API_KEY;
}
// All authenticated browser calls must send cookies:
// credentials: 'include'
// Do NOT store access_token / JWT in localStorage or sessionStorage.Register a new user in your organization. Use credentials: 'include' so any session cookie is stored by the browser.
async function registerUser(email, password, metadata = {}) {
try {
const response = await fetch(`${API_URL}/api/v1/auth/register`, {
method: 'POST',
credentials: 'include',
headers: {
'X-API-Key': getApiKey(),
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email,
password: password,
metadata: metadata,
role: 'user',
}),
});
if(!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Registration failed');
}
const data = await response.json();
// Session cookie (if set) is httpOnly - keep user in app state only
// console.log('User registered:', data.user); // Auto-commented by CI
return data;
} catch(error) {
// console.error('Registration error:', error); // Auto-commented by CI
throw error;
}
}
registerUser('user@example.com', 'securePassword123', {
name: 'John Doe',
company: 'Acme Corp'
}).then(result => {
// console.log('Registration successful:', result.user); // Auto-commented by CI
});Response:
{
"user": {
"id": "user-uuid",
"email": "user@example.com",
"role": "user",
"tenant_id": "org-uuid"
}
}Authenticate and establish an httpOnly cookie session. Prefer POST /api/v1/auth/login-secure for tenant apps (legacy tenant path). Identity Control Plane also exposes POST /v1/session/login.
async function loginUser(email, password) {
try {
// Tenant app / console pattern (useAuth): login-secure + credentials
const response = await fetch(`${API_URL}/api/v1/auth/login-secure`, {
method: 'POST',
credentials: 'include',
headers: {
'X-API-Key': getApiKey(),
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
});
if(!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Login failed');
}
const data = await response.json();
// Cookie session is set by the server - store user in memory/state only
// console.log('Login successful:', data.user); // Auto-commented by CI
return data;
} catch(error) {
// console.error('Login error:', error); // Auto-commented by CI
throw error;
}
}
// Identity plane alternative:
// POST ${API_URL}/v1/session/login with credentials: 'include'
loginUser('user@example.com', 'securePassword123')
.then(result => {
// console.log('Logged in:', result.user); // Auto-commented by CI
});Response:
{
"user": {
"id": "user-uuid",
"email": "user@example.com",
"role": "user",
"tenant_id": "org-uuid"
}
}Read the current session via cookies - no Bearer JWT in browser JS.
async function getCurrentUser() {
try {
// Tenant: GET /api/v1/auth/me - Identity plane: GET /v1/session/me
const response = await fetch(`${API_URL}/api/v1/auth/me`, {
method: 'GET',
credentials: 'include',
headers: {
'X-API-Key': getApiKey(),
},
});
if(!response.ok) {
if(response.status === 401) {
throw new Error('Session expired. Please login again.');
}
const error = await response.json();
throw new Error(error.error || 'Failed to fetch user');
}
const data = await response.json();
// console.log('Current user:', data); // Auto-commented by CI
return data;
} catch(error) {
// console.error('Error fetching user:', error); // Auto-commented by CI
throw error;
}
}
getCurrentUser().then(user => {
// console.log('User email:', user.email); // Auto-commented by CI
// console.log('User role:', user.role); // Auto-commented by CI
});Response:
{
"id": "user-uuid",
"email": "user@example.com",
"role": "user",
"tenant_id": "org-uuid",
"metadata": {
"name": "John Doe"
},
"auth_type": "password",
"auth_providers": []
}Rotate the httpOnly session cookie. Identity plane: POST /v1/session/refresh with credentials and CSRF header.
async function refreshSession() {
// Fetch CSRF for mutating session routes (double-submit)
const csrfRes = await fetch(`${API_URL}/api/v1/auth/csrf`, {
credentials: 'include',
headers: { 'X-API-Key': getApiKey() },
});
const { csrf_token } = await csrfRes.json();
const response = await fetch(`${API_URL}/v1/session/refresh`, {
method: 'POST',
credentials: 'include',
headers: {
'X-API-Key': getApiKey(),
'X-CSRF-Token': csrf_token,
'Content-Type': 'application/json',
},
});
if(!response.ok) {
if(response.status === 401) {
throw new Error('Session invalid. Please login again.');
}
const error = await response.json();
throw new Error(error.error || 'Failed to refresh session');
}
// Cookie rotated server-side - no JWT to store in JS
// console.log('Session refreshed'); // Auto-commented by CI
return response.json().catch(() => ({}));
}
refreshSession().then(() => {
// console.log('Cookie session refreshed'); // Auto-commented by CI
});Notes:
Refresh mutates credentials and requires X-CSRF-Token. Do not implement JWT expiry timers in the browser for LoginMe console auth.
Clear the server session cookie, then clear client user state only (no localStorage JWT cleanup).
async function logoutUser() {
try {
// Identity plane: POST /v1/session/logout (Origin allowlisted; CSRF not required)
// Tenant legacy: POST /api/v1/auth/logout
await fetch(`${API_URL}/v1/session/logout`, {
method: 'POST',
credentials: 'include',
headers: {
'X-API-Key': getApiKey(),
},
});
} catch(error) {
// console.error('Logout error:', error); // Auto-commented by CI
} finally {
// Clear in-memory / React user state only - cookies are cleared by the server
// console.log('User logged out'); // Auto-commented by CI
}
}
logoutUser();Get a list of all users in your organization. Requires admin role. Use cookie session + API key (CSRF on mutating routes).
async function listUsers() {
const response = await fetch(`${API_URL}/api/v1/users`, {
method: 'GET',
credentials: 'include',
headers: {
'X-API-Key': getApiKey(),
},
});
if(!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch users');
}
const data = await response.json();
// console.log('Users:', data); // Auto-commented by CI
return data;
}
listUsers().then(users => {
// console.log(`Found ${users.length} users`); // Auto-commented by CI
users.forEach(user => {
// console.log(`- ${user.email} (${user.role})`); // Auto-commented by CI
});
});Get details of a specific user. Requires admin role.
async function getUser(userId) {
const response = await fetch(`${API_URL}/api/v1/users/${userId}`, {
method: 'GET',
credentials: 'include',
headers: {
'X-API-Key': getApiKey(),
},
});
if(!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch user');
}
return response.json();
}
getUser('user-uuid').then(user => {
// console.log('User:', user.email); // Auto-commented by CI
});Create a new user. Requires admin role. Mutating calls should include CSRF when your tenant enforces it.
async function createUser(email, password, metadata = {}, role = 'user') {
const csrfRes = await fetch(`${API_URL}/api/v1/auth/csrf`, {
credentials: 'include',
headers: { 'X-API-Key': getApiKey() },
});
const { csrf_token } = await csrfRes.json();
const response = await fetch(`${API_URL}/api/v1/users`, {
method: 'POST',
credentials: 'include',
headers: {
'X-API-Key': getApiKey(),
'X-CSRF-Token': csrf_token,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
password,
metadata,
role,
}),
});
if(!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to create user');
}
return response.json();
}
createUser('newuser@example.com', 'securePassword123', {
name: 'Jane Doe',
department: 'Engineering'
}, 'user').then(user => {
// console.log('Created user:', user.email || user.user?.email); // Auto-commented by CI
});Delete a user from your organization. Requires admin role.
async function deleteUser(userId) {
const csrfRes = await fetch(`${API_URL}/api/v1/auth/csrf`, {
credentials: 'include',
headers: { 'X-API-Key': getApiKey() },
});
const { csrf_token } = await csrfRes.json();
const response = await fetch(`${API_URL}/api/v1/users/${userId}`, {
method: 'DELETE',
credentials: 'include',
headers: {
'X-API-Key': getApiKey(),
'X-CSRF-Token': csrf_token,
},
});
if(!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete user');
}
// console.log('User deleted successfully'); // Auto-commented by CI
return true;
}
deleteUser('user-uuid').then(() => {
// console.log('User deleted'); // Auto-commented by CI
});Send an invitation email to a user. Requires admin role.
async function inviteUser(email, role = 'user') {
const csrfRes = await fetch(`${API_URL}/api/v1/auth/csrf`, {
credentials: 'include',
headers: { 'X-API-Key': getApiKey() },
});
const { csrf_token } = await csrfRes.json();
const response = await fetch(`${API_URL}/api/v1/users/invite`, {
method: 'POST',
credentials: 'include',
headers: {
'X-API-Key': getApiKey(),
'X-CSRF-Token': csrf_token,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, role }),
});
if(!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to invite user');
}
return response.json();
}
inviteUser('newuser@example.com', 'user').then(result => {
// console.log('Invitation sent:', result); // Auto-commented by CI
});Get information about your organization.
async function getOrganizationInfo() {
try {
const response = await fetch(`${API_URL}/api/v1/org`, {
method: 'GET',
credentials: 'include',
headers: {
'X-API-Key': getApiKey(),
},
});
if(!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch organization');
}
const data = await response.json();
// Auto-commented by CI
// console.log('Organization:', data); // Auto-commented by CI
return data;
} catch(error) {
// Auto-commented by CI
// console.error('Error fetching organization:', error); // Auto-commented by CI
throw error;
}
}
// Usage
getOrganizationInfo().then(org => {
// Auto-commented by CI
// console.log('Organization name:', org.org.name); // Auto-commented by CI
// Auto-commented by CI
// console.log('Plan:', org.org.plan); // Auto-commented by CI
// Auto-commented by CI
// console.log('User count:', org.org.user_count); // Auto-commented by CI
});Do not redirect directly to /api/v1/auth/social/... or expect a token in the URL. Use the widget social flow: POST /v1/widget/auth/start then POST /v1/widget/auth/callback/exchange, with socialPopup: true.
// Preferred: let the widget own the OAuth popup
LoginMe.init({
apiKey: getApiKey(),
socialPopup: true,
onLogin: ({ user }) => {
// console.log('Social login complete:', user.user_id || user.id); // Auto-commented by CI
},
});
// Under the hood (do not reinvent unless you must):
// 1) POST /v1/widget/auth/start → authorize URL
// 2) OAuth completes → popup hits widget/callback
// 3) POST /v1/widget/auth/callback/exchange (credentials + CSRF)
// 4) postMessage identity to opener; httpOnly session cookie setNote: Prefer popup mode so exchange runs same-site on LoginMe hosts. Redirect mode on a customer origin can fail when browsers block third-party cookies.
Here's a complete example showing a typical user management flow:
// Complete user management example
async function userManagementExample() {
try {
// 1. Login as admin
const loginResult = await loginUser('admin@example.com', 'password123');
// Auto-commented by CI
// console.log('Logged in as:', loginResult.user.email); // Auto-commented by CI
// 2. Get organization info
const orgInfo = await getOrganizationInfo();
// Auto-commented by CI
// console.log('Organization:', orgInfo.org.name); // Auto-commented by CI
// 3. List all users
const users = await listUsers();
// Auto-commented by CI
// console.log(`Total users: ${users.length}`); // Auto-commented by CI
// 4. Create a new user
const newUser = await createUser(
'newuser@example.com',
'securePassword123',
{ name: 'New User' },
'user'
);
// Auto-commented by CI
// console.log('Created user:', newUser.user.email); // Auto-commented by CI
// 5. Get user details
const userDetails = await getUser(newUser.user.id);
// Auto-commented by CI
// console.log('User details:', userDetails); // Auto-commented by CI
// 6. Get current user info
const currentUser = await getCurrentUser();
// Auto-commented by CI
// console.log('Current user:', currentUser.email); // Auto-commented by CI
// 7. Logout
await logoutUser();
// Auto-commented by CI
// console.log('Logged out'); // Auto-commented by CI
} catch(error) {
// Auto-commented by CI
// console.error('Error in user management flow:', error); // Auto-commented by CI
}
}All API calls should handle errors appropriately:
async function safeApiCall(apiFunction) {
try {
const result = await apiFunction();
return { success: true, data: result };
} catch(error) {
// Auto-commented by CI
// console.error('API Error:', error); // Auto-commented by CI
return {
success: false,
error: error.message || 'Unknown error'
};
}
}
// Usage
const result = await safeApiCall(() => loginUser('user@example.com', 'password'));
if(result.success) {
// Auto-commented by CI
// console.log('Login successful:', result.data); // Auto-commented by CI
} else {
// Auto-commented by CI
// console.error('Login failed:', result.error); // Auto-commented by CI
}Browser sessions are httpOnly cookies. Refresh the cookie session when needed - do not run JWT expiry timers in page JavaScript.
// Optional: refresh cookie session periodically (CSRF required)
async function keepSessionAlive() {
try {
await refreshSession(); // see Refresh Session section
} catch(error) {
// console.error('Session refresh failed:', error); // Auto-commented by CI
// Redirect to login - clear in-memory user state only
}
}
// After login-secure / widget onLogin - no JWT storage
loginUser('user@example.com', 'password').then(() => {
// Optionally schedule keepSessionAlive(); cookie handles auth
});
logoutUser().then(() => {
// Clear React/app user state; server cleared cookies
});After the browser has a LoginMe httpOnly session (widget or login), use a dual path:
GET /v1/session/me - email / org membership. Never authorize from this alone.Authorization: LoginMe <jwt>, verify with JWKS.Mint and GET /v1/session/me require an allowlisted Origin and a session cookie the browser actually sends to api.loginme.io. Do not treat widget onLogin as proof the cookie is present - always dual-path load. See above.
Assertions are not LoginMe console tokens - do not store them in localStorage/sessionStorage, do not put them in URLs or logs, and do not treat them as a substitute for the cookie session. Hold the JWT in memory only for the outbound request. Scheme is LoginMe, not Bearer. XSS on your page can still mint (cookie + API key) - harden XSS; never authorize from the widget alone.
CSRF + POST /v1/session/assertion with credentials: 'include'.audience must match what your backend configures as ExpectedAudience. Your app Origin must be allowlisted for the tenant (same as other /v1/session/* routes).
const API_URL = 'https://api.loginme.io';
// Opaque audience string - must match your backend LOGINME_AUDIENCE
const AUDIENCE = 'https://api.yourapp.com';
async function fetchCsrfToken(apiKey) {
const res = await fetch(`${API_URL}/api/v1/auth/csrf`, {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
'X-API-Key': apiKey,
},
});
if(!res.ok) throw new Error('CSRF failed');
const data = await res.json();
return data.csrf_token;
}
async function mintAssertion(apiKey, audience = AUDIENCE) {
const csrf = await fetchCsrfToken(apiKey);
const res = await fetch(`${API_URL}/v1/session/assertion`, {
method: 'POST',
credentials: 'include',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-API-Key': apiKey,
'X-CSRF-Token': csrf,
},
body: JSON.stringify({ audience }),
});
if(!res.ok) throw new Error('Assertion mint failed');
// { assertion, expires_in } - short TTL; no email/name in JWT (opaque ids only).
// Do not log or persist the assertion string.
return res.json();
}Mint a fresh assertion per request (or within TTL), then call your backend. Use scheme LoginMe - Bearer is rejected by ginmw when Scheme is LoginMe.
async function yourApiFetch(apiKey, path, { method = 'GET', body } = {}) {
const { assertion } = await mintAssertion(apiKey);
const headers = {
Accept: 'application/json',
Authorization: `LoginMe ${assertion}`, // NOT Bearer
};
if(body !== undefined) {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(`https://api.yourapp.com${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if(!res.ok) throw new Error(`API error ${res.status}`);
if(res.status === 204) return null;
return res.json();
}
// Authz claims after verify (example): GET /api/me → { sub, tenant_id, roles }
// Prefer not echoing session_id to the browser UI.
const claims = await yourApiFetch(apiKey, '/api/me');Use loginme/customertrust/ginmw AssertionMiddleware.Reject empty trust config at process start - ginmw skips issuer/audience/tenant checks when those expected values are empty (fail-open). Prefer a jti replay guard for mutating routes within the assertion TTL.
import(
"fmt"
"strings"
"time"
"loginme/customertrust/ginmw"
"github.com/gin-gonic/gin"
)
jwksURL := "https://api.loginme.io/.well-known/jwks.json" // or https://api.loginme.io/v1/jwks if JWKS is gated
issuer := "https://api.loginme.io"
audience := "https://api.yourapp.com" // must match mint audience
tenantID := "<your-tenant-uuid>"
// Fail closed: empty env/config must not reach AssertionMiddleware
if strings.TrimSpace(jwksURL) == "" || strings.TrimSpace(issuer) == "" ||
strings.TrimSpace(audience) == "" || strings.TrimSpace(tenantID) == "" {
panic(fmt.Errorf("LOGINME trust config incomplete (JWKS/issuer/audience/tenant)"))
}
authMW := ginmw.AssertionMiddleware(ginmw.AssertionMiddlewareConfig{
JWKSURL: jwksURL,
ExpectedIssuer: issuer,
ExpectedAudience: audience,
ExpectedTenantID: tenantID,
ClockSkew: time.Minute,
Scheme: "LoginMe", // not Bearer
})
api := r.Group("/api", authMW) // add jti replay middleware for POST/DELETE when needed
api.GET("/me", func(c *gin.Context) {
claims, ok := ginmw.LoginMeAuthClaims(c)
if !ok {
c.AbortWithStatusJSON(401, gin.H{"error": "missing claims"})
return
}
// Authorize from verified claims (sub, roles, tenant_id) - never from session/me alone.
// Do not return email/name (not in assertion). Avoid echoing session_id to browsers.
c.JSON(200, gin.H{
"sub": claims["sub"],
"tenant_id": claims["tenant_id"],
"roles": claims["roles"],
})
})Do not trust widget onLogin as authority (UI trigger only). Load profile and authz claims in parallel; require both and bind profile.user_id === claims.sub. Use email from session/me for display only - never for authz.
async function fetchSessionMe(apiKey) {
const res = await fetch(`${API_URL}/v1/session/me`, {
credentials: 'include',
headers: { Accept: 'application/json', 'X-API-Key': apiKey },
});
if(!res.ok) throw new Error('session/me failed');
return res.json(); // { user_id, email, role, tenant_id, ... } - UI identity only
}
async function loadSessionSurfaces(apiKey) {
const [profileSettled, claimsSettled] = await Promise.allSettled([
fetchSessionMe(apiKey),
yourApiFetch(apiKey, '/api/me'),
]);
if(profileSettled.status !== 'fulfilled' || claimsSettled.status !== 'fulfilled') {
return { ok: false }; // fail closed
}
const profile = profileSettled.value;
const claims = claimsSettled.value;
if(!claims?.sub) return { ok: false };
// Prefer user_id from session/me; identity_id/sub are shape fallbacks only
const userId = profile.user_id || profile.identity_id || profile.sub;
if(!userId || String(userId) !== String(claims.sub)) {
return { ok: false }; // subject mismatch - refuse signed-in UI
}
return { ok: true, profile, claims };
}
// Widget: onLogin only triggers dual-path load - never authorize from the callback alone
LoginMe.init({
apiKey,
socialPopup: true,
onLogin: async() => {
const surfaces = await loadSessionSurfaces(apiKey);
if(!surfaces.ok) { /* show error; stay signed out */ return; }
// UI from surfaces.profile (email); authz from surfaces.claims (sub/roles)
},
});Mint response: { assertion, expires_in }. TTL is typically a few minutes. Full working reference: the Acme Notes integrator demo (loginme-integrator-demo).
X-API-Key where required; store keys securely (shown once at creation)credentials: 'include' - never put LoginMe JWTs in localStorage/sessionStoragePOST /v1/session/assertion + CSRF with matching audience; call your API as Authorization: LoginMe <jwt> (not Bearer); verify with ginmw + /.well-known/jwks.json; reject empty issuer/audience/tenant at startup; never store or log the assertion JWTerror field
12. Social Login - List Providers
Get list of available social login providers.