Add sign-in
You end with sign-in by emailed link and with Google, and routes that know who is calling.
On this page
Jadpo writes the sign-in routes for you: you say which ways in your app accepts, which stored record is a person, and what happens the first time someone new signs in. This guide lets people sign in to a book club with an emailed link, or with their Google account, and gives every route a signed-in caller.
You start from an app with one domain, club, and nothing stored yet.
Say who a person is
Each person is a record. A member is found by the address they sign in with:
domains/:
/// Someone in the book club.entity Member { id: Uuid email: Email { unique: true } policy: { create: [Access.system] read: [id] }}
Access.system means only Jadpo itself may create a member, on a first
sign-in, and read: [id] means each member may read only their own
record.
Connect Google and a mail server
Sign-in emails go out through a mail server, and Google is a sign-in
provider. Both are connections in app.jadpo, and principal says
which record a signed-in person is:
app.jadpo:
use club/entities { Member }application { display_name: "The book club" connections: { google: { driver: Driver.oidc issuer: config.google_issuer client_id: config.google_client_id credential: config.google_client_secret } mail: { driver: Driver.smtp host: config.smtp_host } } email: { connection: mail from: config.mail_from }}principal { user: { subject: Member.email }}
Driver.oidcis for Google and any other OpenID Connect provider; Google's discovery document gives Jadpo the rest.principal { user: ... }says a signed-in person is aMember, found by theiremail.
The settings it reads go in config.jadpo:
config.jadpo:
config { /// The client ID Google gave the app. google_client_id: Text { test: "book-club" } /// The client secret Google gave the app. google_client_secret: Text { secret: true } /// Google's address as an OpenID Connect issuer. google_issuer: Url { default: "https://accounts.google.com" } /// Who the app's emails come from. mail_from: Email { default: "club@example.com" } /// The address of the site people sign in on. site: Url { default: "http://localhost:3000" } /// The mail server the app hands its emails to. smtp_host: Hostname { default: "localhost" }}
google_client_secret is a secret: only the connection can read it,
and it never appears in a log or an error. It has no default, so a
deployed app won't start without it. Give it one on your machine with
jadpo config set google_client_secret, which asks for the value at a
prompt. jadpo dev doesn't need any of the three Google settings,
since it never reaches Google (below).
In Google's developer console, create an OAuth client for a web
application and register <your site>/ as its
redirect address, then use the client ID and secret it gives you.
Turn sign-in on
authentication.jadpo, beside app.jadpo:
use clubauthentication { browser_session: { cookie: "club_session" origin: config.site } sign_in: { magic_link: { link: "/sign-in" } providers: [google] } on_first_sign_in: club.welcome_member}
browser_session:keeps a signed-in person in a cookie.origin:is the address your site is served from; a write is accepted only from there.magic_link:emails a link and a code.link:is the page of your site the link opens, which sends the link's token on.providers: [google]names the connection to sign in through.on_first_sign_in:names the action that decides what happens the first time someone signs in whom the app has no record for, whichever way they came in.
Decide who may join
domains/:
/// The person signing in has no address the app can trust.type AddressUnverified = Failure { kind: FailureKind.rejected}
domains/:
/// Someone has that address already.type AlreadyAMember = Failure { kind: FailureKind.conflict}
domains/:
use club/entities { Member }use club/failures { AddressUnverified, AlreadyAMember }/// Makes a member of someone signing in for the first time, by either/// way in, once their address is known to be theirs.action welcome_member(first: FirstSignIn) fails AddressUnverified, AlreadyAMember -> Member.id { if (not first.address_verified) { reject AddressUnverified } match first.address { none -> { reject AddressUnverified } some(address) -> { var member: Member = attempt Member.create { set: { email: address } conflict: { Member.email: AlreadyAMember } } return member.id } }}
It runs as the system, and gives back the new member's id. Any
failure refuses the person, who sees only sign_in_refused. A magic
link proves the address is theirs, so address_verified is true;
from Google it is true only when Google has verified the address.
Jadpo keeps the link between a Google account and its member, so a
later Google sign-in finds them without running this again. Here
anyone may join; a club that takes on members itself would refuse
addresses it doesn't know.
Use the person signed in
Every route needs a signed-in caller unless it says otherwise, so a
route you write is protected from the start. current_principal.user
is the member making the request:
domains/:
use club/entities { Member }/// A member, as they see themselves.type MemberView = Output { pick: [Member.id, Member.email]}
domains/:
use club/entities { Member, MemberNotFound }use club/types { MemberView }/// One member.query member_by_id(member_id: Member.id) fails MemberNotFound -> MemberView { from: Member where: Member.id == member_id missing: MemberNotFound}
domains/:
use club/types { MemberView }use club/queries { member_by_id }/// The signed-in member.routes Me { prefix: /me /// Who is signed in. GET / { output: MemberView run: member_by_id(current_principal.user.id) }}
Run jadpo migrate new initial, jadpo api update and jadpo check.
The API record now lists the routes Jadpo wrote: POST /
and POST /, and POST / and
GET /.
Try it
jadpo devjadpo dev, started 10:53:43listening on http://localhost:3000 routes 6 routes settings 6 checked database disposable PostgreSQL, gone when dev stops inbox http://localhost:3001 · mail caught, never sent sign-in http://localhost:3002 · test accounts in the provider's placeserving: saves reload, press ctrl-c to quit
jadpo dev catches every email in its inbox, and stands in for Google
with test accounts of its own. Ask for a sign-in email:
curl -i -X POST http://localhost:3000/auth/magic-link \ -H 'content-type: application/json' \ -d '{"address": "ann@example.com"}'
HTTP/1.1 202 Acceptedcache-control: no-storex-request-id: req_7861b51220294fb6content-length: 0date: Sat, 31 Oct 2026 10:53:32 GMT
The answer is the same whether or not the address has a member, so
nobody can use it to find out who has joined. Read the email in the
inbox at http:/, or in a terminal:
jadpo email show mail_1mail_1 Sign in to The book club email jadpo.MagicLinkEmail from club@example.com to ann@example.com at 2026-10-31T10:53:32.436Z trace 9e08c48a2b3cbfbbce4997a6921d0608 parts html 1.2 KB · text 295 Btext Sign in Someone asked to sign in as ann@example.com. Sign in: http://localhost:3000/sign-in?token=d544502cc80a32e802c7bb37d74f772d093c1ec43fc91a3cd7c21dacce647a8f Or enter this code: 774111 The link and the code work for 15 minutes. If you didn't ask to sign in, you can ignore this email.
Your site's / page sends the token on. Here, send it yourself,
using the token from your own email, and keep the cookies it sets in
a file outside your project:
curl -i -c ~/club-cookies.txt -X POST http://localhost:3000/auth/magic-link/confirm \ -H 'content-type: application/json' \ -H 'origin: http://localhost:3000' \ -d '{"token": "d544502cc80a32e802c7bb37d74f772d093c1ec43fc91a3cd7c21dacce647a8f"}'
HTTP/1.1 200 OKcontent-type: application/jsonset-cookie: club_session=3a23e044e74acbe02edacf5a7878ca4757c725ed276c698f5d6dab8dca98ecdd; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Strictset-cookie: club_session_csrf=0ac0390662cb7c383443378e53a8348361ad3e6e18935902b3ff64804717d4c2; Path=/; Max-Age=2592000; Secure; SameSite=Strictcache-control: no-storex-request-id: req_0ab613ffd12d535acontent-length: 17date: Sat, 31 Oct 2026 10:53:33 GMT{"return_to":"/"}
Ann's first sign-in made her a member, and her session lasts 30 days:
curl -b ~/club-cookies.txt http://localhost:3000/me{"id":"01a19197-e5ad-7edf-ba42-a66ef56fc50d","email":"ann@example.com"}Every write with the session cookie also needs the origin header and
an x-jadpo-csrf header holding the club_session_csrf cookie's value,
so another site can't make the browser send one. Your front end sends
both on every write.
To sign in with Google, your page posts to / and
sends the browser to the address it answers. In jadpo dev that is a
page on http:/ listing test accounts; choosing one, or
adding one, signs in as it, and the browser comes back to
/ as it would from Google.
Test it
A route test asks for a link and reads the email it sent, and
test_providers.sign_in signs in through the provider's routes, with a
local stand-in for Google:
tests/:
test "an emailed link signs Ann in" { var asked: Response = request { method: HttpMethod.post path: "/auth/magic-link" input: MagicLinkAsk { address: "ann@example.com" } } assert asked.status == 202 match list.first(email.sent) { none -> { assert list.count(email.sent) == 1 } some(sent) -> { var confirmed: Response = request { method: HttpMethod.post path: "/auth/magic-link/confirm" input: MagicLinkConfirmation { token: sent.sign_in_token } } assert confirmed.status == 200 } }}test "a verified Google account signs Bo in" { var signed_in: Response = test_providers.sign_in( SignInMethod.google, TestAccount { subject: "110248495921238986420" email: "bo@example.com" email_verified: true name: "Bo Lind" } ) assert signed_in.status == 303 assert signed_in.code == none}test "a Google account without a verified address is refused" { var refused: Response = test_providers.sign_in( SignInMethod.google, TestAccount { subject: "2001" email: "cy@example.com" email_verified: false name: none } ) assert refused.code == "sign_in_refused"}test "who is signed in needs someone signed in" { var response: Response = request { method: HttpMethod.get path: "/me" } assert response.status == 401}
jadpo testtested: 4 passed, 4 route testsOther tests sign in with as: Principal.user(member) in a request,
without going through the routes; Write tests shows
how.
Keep staff to the company's accounts
When some people, such as staff, should keep their powers only while their company's provider vouches for them, two lines do it:
- On the provider's connection,
organisation: config.google_domainreads the company's Google Workspace domain, or its Microsoft tenant ID, from aTextsetting. An account outside it can't sign in through the provider. - On the membership that gives them their role,
requires:gives each role a line:OrganiserRole.host: { sign_in: [SignInMethod.google] }lets the role count only for someone signed in through Google, andstrength: AuthenticationStrength.multi_factorasks for a second factor too;OrganiserRole.reader: {}needs nothing. Signed in by link, an organiser is a member like any other, so switching their Google account off ends their powers, and a Google session lasts a day.
A route test then says how its user signed in, sign_in: SignInMethod.google beside as:. The lending desk example
(examples/) does all three. See
Roles that need a way in
and Company accounts.
Read more
The principal, Ways to sign in, Checking the person, Magic links, Passkeys, A person the app has not seen, Routes: signed in by default and Who is calling. To let each person see only their own records, see A user field on the record.
Not built yet
Signing in with GitHub or Apple isn't built yet, since neither is plain OpenID Connect. Use magic links, passkeys, or Google and other OpenID Connect providers.
Markdown for agents: /guides/guide-add-sign-in.md.Built from Jadpo main at e00e61f. Jadpo's CI compiles this guide's code and runs its tests.