TL;DR: Auth ships inside every LingCode Cloud backend โ no separate service to stand up. Users sign in with email/password, a magic link, an email OTP code, or Google/GitHub/Apple OAuth; you can require TOTP multi-factor. After sign-in the client SDK stores the session and attaches it to every later .from() call, so row-level security keys off the signed-in user's id. The anon key is public; the user session token is what authenticates a person.
If you've used Firebase Auth or Supabase Auth, the shape is familiar: a few sign-in methods, a session token the SDK carries for you, and server-side rules that decide what each user can see. This page explains the model first โ why the anon key is safe to ship, what the session token actually is, and how multi-factor raises the bar โ and then walks each method with runnable code. The lingcode client in the samples is the SDK client; inside /try and the Mac preview it's pre-injected as window.lingcode.
Every backend exposes two kinds of credential, and keeping them straight is most of understanding auth here.
auth.uid()-style rules start matching, and each user sees only their own rows.It feels wrong to put a key in client JavaScript anyone can read. The reason it's fine: the anon key is an address, not a permission. It tells the gateway which backend you mean and lets unauthenticated reads through to whatever your policies mark public โ nothing more. The thing that actually gates private data is the user session token plus your row-level-security policies on the server. Treat the anon key like a public URL and your real secrets (Stripe keys, vendor API keys) like passwords โ those go in the Secrets vault, never the client.
A successful sign-in resolves to a Session. The SDK persists it for you, so you rarely read the token by hand:
Session = {
user: { id, email } | null,
token: string // a signed JWT representing the user
}
Every auth call returns a Result โ { data, error } โ except the few noted below. Check error before trusting data.
client.ready once on load. If the user is returning from an OAuth redirect or a magic link, the session arrives in the URL and the SDK has to consume it before getUser() can tell you who's signed in. await lingcode.ready resolves after that's done โ call it once before reading the user, or a freshly-redirected user will look signed-out for a frame.
Email/password, magic link, and email OTP are always available on every backend. The three managed OAuth providers โ Google, GitHub, Apple โ are operator-configured and shared, or you can bring your own per-backend OAuth credentials. Enable and manage OAuth providers and the "require MFA" setting from the console.
Classic credentials. signUp creates the account, signIn returns a session. Always on.
Email a one-tap sign-in link. No password to remember; the SDK finalizes the link on return.
Email a short numeric code the user types back. Good for mobile and passwordless flows.
Google, GitHub, Apple (managed or bring-your-own), or a custom provider string. Top-level redirect.
Authenticator-app codes layered on any of the above. Raises the session to AAL2.
The simplest path. signUp registers the account and signIn (alias signInWithPassword) returns a session. Both take { email, password } and return Result<Session>.
// Register
const { data, error } = await lingcode.auth.signUp({
email: '[email protected]',
password: 'a-strong-passphrase'
});
// Sign in (signInWithPassword is an alias)
const { data: session, error: err } = await lingcode.auth.signIn({
email: '[email protected]',
password: 'a-strong-passphrase'
});
if (!err) {
console.log('signed in as', session.user.email);
}
After a successful call the SDK has already stored the session โ your next lingcode.from(...) call goes out authenticated. You don't pass the token anywhere yourself.
A magic link trades the password for a one-time link emailed to the user. You send the link; when the user clicks it they land back on your app with the session in the URL, and the SDK auto-finalizes it โ which is why you rarely call verifyMagicLink directly.
// 1. Send the link
const { data, error } = await lingcode.auth.sendMagicLink({
email: '[email protected]',
redirectTo: 'https://your-app.example.com/welcome' // optional
});
// data => { sent: true }
// 2. On the page the link returns to, just await ready โ
// the SDK consumes the token from the URL for you.
await lingcode.ready;
const user = lingcode.auth.getUser(); // now populated
// (rarely needed) finalize a token by hand:
// await lingcode.auth.verifyMagicLink(tokenFromUrl);
OTP is the same idea as a magic link but with a short code the user types instead of a link they click โ handy on mobile or when deep links are awkward. Send a code, then verify the code the user enters.
// 1. Send a code
await lingcode.auth.sendOtp({ email: '[email protected]' });
// => { sent: true }
// 2. Verify what the user typed
const { data: session, error } = await lingcode.auth.verifyOtp({
email: '[email protected]',
code: '482913'
});
if (!error) {
// session is stored; user is signed in
}
OAuth hands sign-in off to a provider the user already trusts. signInWithOAuth takes a provider โ 'google', 'github', 'apple', or a custom string โ and performs a top-level navigation to that provider's consent screen. It returns void, not a Result, because the page is leaving; on return, the SDK auto-stores the session.
// Kicks off a full-page redirect to Google.
lingcode.auth.signInWithOAuth('google', {
redirectTo: 'https://your-app.example.com/dashboard' // optional
});
Because it's a top-level navigation, don't expect code after this line to run with a result โ the browser is already on its way to the provider. Handle the signed-in state on the page the user comes back to (await ready, then read the user).
Not every backend turns on every provider. Ask before you render the buttons โ getProviders() returns a map of provider โ availability, so you can show only the ones that work:
const providers = await lingcode.auth.getProviders();
// {
// google: { available: true, source: 'managed' },
// github: { available: true, source: 'byo' },
// apple: { available: false, source: null }
// }
if (providers.google?.available) {
renderGoogleButton();
}
If the provider round-trip failed (user cancelled, mis-configured client, etc.), the error code rides back on the URL. lastError() surfaces it so you can show a message after a redirect:
await lingcode.ready;
const err = lingcode.auth.lastError(); // e.g. 'access_denied' or null
if (err) showBanner(`Sign-in failed: ${err}`);
You have two ways to wire up Google, GitHub, or Apple. Managed providers are operator-configured and shared across backends โ the fastest path, nothing for you to register. Bring-your-own lets you supply your own client id and secret per backend in the console, so the consent screen shows your app's name and you own the OAuth app. Switch between them and flip the "require MFA" setting from the console at lingcode.dev/backends โ your backend โ auth providers. Step-by-step: Sign in with Google and Sign in with GitHub.
A password or OAuth login proves the user knows a secret or controls an account โ assurance level AAL1. MFA adds a second proof: a time-based one-time code from an authenticator app (TOTP โ RFC 6238, 6 digits, a fresh code every 30 seconds, with ยฑ1 step of drift tolerance so a slightly-off clock still verifies). Completing MFA raises the session to AAL2, the higher assurance level your sensitive operations can require.
The flow has two parts. Enroll once: the backend returns a secret plus a QR code, the user scans it into their authenticator app, and you verify a code to confirm the pairing. Verify thereafter: when a session needs AAL2, the user enters a current code and the session is promoted. If the backend has "MFA required" turned on, users must complete MFA before they're considered fully signed in.
Splitting assurance into AAL1 and AAL2 lets you draw a line: routine reads happen at AAL1, but a policy can demand AAL2 for the dangerous stuff โ deleting an account, moving money, changing security settings. A stolen password gets an attacker to AAL1; without the user's authenticator device they can't reach AAL2, so the high-value paths stay closed. Requiring MFA on the backend simply makes AAL2 the bar for being "signed in" at all.
MFA is driven through the REST endpoints below โ enroll to get the secret and QR, then verify a code to reach AAL2:
// Enroll: returns { secret, qr_code } โ render the QR for the user to scan
POST /api/cloud/be/<backend-id>/auth/mfa/enroll
// Verify a code to confirm enrollment / raise the session to AAL2
POST /api/cloud/be/<backend-id>/auth/mfa/verify { "code": "482913" }
Full walkthrough, including turning on the required-MFA switch: Require MFA on your backend.
The session token is a signed JWT that represents the user. The SDK carries it for you, but it helps to know what's moving:
/auth/token/refresh directly.?apikey=) for anonymous / RLS-public access, or the user token (attached automatically by the SDK) for signed-in access. Same endpoints, different credential.Reading the current user or token by hand, when you need to:
const user = lingcode.auth.getUser(); // { id, email } | null
const token = lingcode.auth.getToken(); // string | null (the JWT)
await lingcode.auth.signOut(); // clears the stored session; resolves { error: null }
The SDK is a thin wrapper over these endpoints โ reach for them directly if you're working outside JavaScript or want to see exactly what's happening. The base for a backend is https://lingcode.dev/api/cloud/be/<backend-id>. The sign-in and provider endpoints authenticate with the anon key (Bearer or ?apikey=); the MFA, refresh, and sign-out endpoints require the user token.
| Method | Path | Body / notes |
|---|---|---|
| POST | /auth/signup | { email, password } |
| POST | /auth/signin | { email, password } |
| POST | /auth/magiclink/request | { email, redirect_url } |
| POST | /auth/magiclink/verify | { token } |
| POST | /auth/otp/request | { email } |
| POST | /auth/otp/verify | { email, code } |
| POST | /auth/apple/native | { identity_token } โ native Apple Sign In |
| POST | /auth/token/refresh | { refresh_token } โ requires user token |
| POST | /auth/signout | { refresh_token } or { all: true } โ requires user token |
| POST | /auth/mfa/enroll | โ { secret, qr_code } โ requires user token |
| POST | /auth/mfa/verify | { code } โ session at AAL2 โ requires user token |
| POST | /auth/mfa/challenge | requires user token |
| GET | /auth/mfa/factors | requires user token |
| DELETE | /auth/mfa/factors/:factorId | requires user token |
| GET | /auth/providers | โ { google:{available,source}, github:{โฆ}, apple:{โฆ} } |
| GET | /auth/oauth/:provider/start?redirect_url=โฆ | โ 302 to the provider's consent screen |
The OAuth round-trip finishes at a callback the platform owns:
GET/POST /api/cloud/auth/oauth/:provider/callback
// On success, redirects back to your redirect_url with:
// ?lc_session=<jwt>
// On failure:
// ?lc_error=<code>
?apikey=). The MFA endpoints, token refresh, and sign-out require the user token โ you can't enroll a factor or refresh a session with the anon key alone. The SDK picks the right one for you; if you're calling REST by hand, send the matching credential.