triauth for Bun
Sign-in for a Bun.serve server, with the official library from npm and nothing else.
- Library
- triauth-js
- Requires
- Bun 1.2.7 or newer
- Install
bun add triauth- Language
- TypeScript
Intro
triauth-js, the official open-source library, runs on Bun 1.2.7 and newer with no dependencies.
Sign-in is two simple calls to the library, with no passwords to store, protect, or reset.
Integrate triauth with your application in an afternoon and boost your metrics by offering a simple, effortless way for users to sign in and try it. Once you do, make sure to let us know - we love featuring integrations on the triauth project's website.
Get started
This guide shows you how to add "Sign in with triauth" to a Bun server with triauth-js. At the end you have a server with a sign-in form that sends the user to their authenticator and signs them in when they approve. It takes about fifteen minutes.
How sign-in works
A sign-in has three stages, and your server calls Triauth.authenticate twice:
// 1. request: start a sign-in for the identifier the user typed
const { challenge, redirectUrl } = await Triauth.authenticate({ identifier, callbackUrl });
// 2. redirect: keep the challenge on the server, send the user to their authenticator
session.challenge = challenge;
redirect(redirectUrl);
// 3. verify: the browser comes back to callbackUrl with a response
const result = await Triauth.authenticate({ challenge: session.challenge, response });
delete session.challenge; // a challenge is good for one attempt only
if (result.authenticated) session.identifier = result.identifier;We will follow these stages in this tutorial, and start by building a sign-in page, then follow the request through the authenticator, and finally verify the received response. See the sign-in flow guide for more details.
Before you start
- Bun 1.2.7 or newer. Check with
bun --version. - A triauth identity of your own, set up in the browser you test with. See Set up your identity.
- An empty folder, or an existing project with a
package.json.
1. Install the library
Add triauth-js to your project. It has no other dependencies.
bun add triauth2. Create the sign-in page
Create server.ts. It serves a sign-in form at /, and the form posts the identifier to /login, where stages 1 and 2 run. The server uses the routes of Bun.serve and the cookies of the request.
import * as Triauth from 'triauth';
import type { BunRequest } from 'bun';
// the address the authenticator sends the browser back to, never built from request headers
const callbackUrl = 'http://localhost:3000/callback'; // your public address in production
// sessions in memory, keyed by the sid cookie, so a restart signs everyone out
const sessions = new Map<string, Session>();
type Session = { identifier?: string; challenge?: string; error?: { code: string; message: string } };
Bun.serve({
port: 3000,
routes: {
// the home page: the identifier when signed in, the sign-in form otherwise
'/': {
GET: (req) => {
const session = getSession(req);
if (session.identifier) return html(`Signed in as ${session.identifier}`);
const error = session.error?.message ?? '';
delete session.error;
return html(`
<form action="/login" method="post">
<input name="identifier" placeholder="you@example.com">
<button>Sign in with triauth</button>
<p style="color: red">${error}</p>
</form>`);
}
},
// the sign-in form posts here
'/login': {
POST: async (req) => {
// make sure the sign-in is from this site's own pages
const site = req.headers.get('sec-fetch-site');
if (site && site !== 'same-origin') return new Response('Forbidden', { status: 403 });
const session = getSession(req);
const form = await req.formData();
const identifier = String(form.get('identifier') ?? '');
// 1. request
const result = await Triauth.authenticate({ identifier, callbackUrl });
// 2. redirect, when the result holds a challenge
if ('challenge' in result) {
session.challenge = result.challenge; // kept on the server, where the user cannot change it
return redirect(result.redirectUrl);
}
// an invalid identifier, or a domain that is not set up for triauth
if ('error' in result) session.error = result.error;
return redirect('/');
}
},
},
fetch: () => new Response('Not found', { status: 404 })
});
console.log('Listening on http://localhost:3000');
// finds the session by its sid cookie, or starts a new one
function getSession(req: BunRequest): Session {
const id = req.cookies.get('sid');
const session = id && sessions.get(id);
if (session) return session;
const newId = crypto.randomUUID();
req.cookies.set('sid', newId, { httpOnly: true, sameSite: 'lax' }); // Bun adds the cookie to the response
const fresh: Session = {};
sessions.set(newId, fresh);
return fresh;
}
function html(body: string) {
return new Response(body, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
function redirect(location: string) {
return new Response(null, { status: 302, headers: { Location: location } });
}The /login route runs stages 1 and 2. Triauth.authenticate reads the DNS records of the identifier's domain, finds the user's authentication endpoint there, and returns a challenge and a redirectUrl. If the identifier is malformed, or its domain is not set up for triauth, it returns an error instead, and the form shows its message. The challenge goes into the session, and the browser is redirected to the authenticator. The result is a union of three shapes, so the code checks with in which one it got.
A few parts of this file matter for your own app too.
The session keeps the challenge between the two calls, where the user cannot change it. This server keeps its sessions in memory, keyed by a random sid cookie, which is enough for a tutorial. In your app, use the session store you already have. The cookie is SameSite=Lax, so the browser sends it when the authenticator brings the user back to your site.
The sec-fetch-site check refuses a sign-in form that another website posted. Without it, another website could post an attacker's identifier to /login from the user's browser, and your server would sign the user in to the attacker's account. Browsers set this header to same-origin for a form from your own pages. A CSRF token does the same job. See Stage 1 of the sign-in flow.
callbackUrl is fixed configuration. Do not build it from the Host header of the request, which the client controls.
The redirect to the authenticator must keep the Referer header, which the authenticator compares with your callback URL. The browser's default policy sends your origin, so this works unless your pages set a no-referrer policy.
Start the server:
bun server.tsOpen http://localhost:3000 in the browser where your identity is set up, and enter your identifier.
Checkpoint
You should see the form, then your authenticator asking you to approve a sign-in to http://localhost:3000/.
3. Approve the request
The browser is now at the user's authenticator, with the challenge in the URL. The authenticator shows the address of your website and the identifier, and asks the user to approve or deny.
Click Approve.
Checkpoint
The browser lands on http://localhost:3000/callback?response= followed by the encrypted response, and the page says "Not found". The server has no route for /callback yet. The next step adds it.
4. Receive the response and sign the user in
In server.ts, add the callback route after the /login route.
// the sign-in form posts here
'/login': {
// ...
},
// 3. verify: the authenticator sends the browser back here, with the response in the query string
'/callback': {
GET: async (req) => {
const session = getSession(req);
const response = new URL(req.url).searchParams.get('response') ?? '';
const result = await Triauth.authenticate({ challenge: session.challenge ?? '', response });
delete session.challenge; // a challenge is good for one attempt only
if ('authenticated' in result) session.identifier = result.identifier;
else if ('error' in result) session.error = result.error; // denied, expired, or not verified
return redirect('/'); // always redirect, so that the response does not stay in the address bar
}
}
},
fetch: () => new Response('Not found', { status: 404 })This route runs the response verification stage. It reads response from the query string and passes it to Triauth.authenticate together with the challenge from the session. The library decrypts the response with the key in the challenge, reads the identity records again, and checks the signature against the public keys published there. On success, result.authenticated is true and result.identifier is the verified identifier, which the route stores in the session. On failure, result.error says why, with a code such as request_denied or request_expired.
The challenge is deleted from the session before the result is read, so that one challenge serves one attempt. The route always ends with a redirect to /, where the home page shows the signed-in state, and the response does not stay in the address bar. Receiving the response explains how the response reaches /callback and what it leaves in your logs.
The result has more fields than the identifier, such as the name of the device that signed and a session key for the requests that follow. Read Stage 3 of the sign-in flow for the full list and what to store.
Stop the server with Ctrl+C, start it again, and sign in once more:
bun server.tsCheckpoint
After you approve, you should see the page "Signed in as" followed by your identifier. If you deny the request, the form comes back with "Request has been denied" in red.
5. Celebrate 🎉
You now have a fully functional "Sign in with triauth" flow. But don't stop yet - there is so much more you can do with triauth! You can periodically validate sessions with Triauth.check and Triauth.ping, ask for terms acceptance with Triauth.sign, and verify that the user is not a robot with Triauth.attest. See the triauth-js README to learn more, or visit the triauth playground.
The complete server.ts
import * as Triauth from 'triauth';
import type { BunRequest } from 'bun';
// the address the authenticator sends the browser back to, never built from request headers
const callbackUrl = 'http://localhost:3000/callback'; // your public address in production
// sessions in memory, keyed by the sid cookie, so a restart signs everyone out
const sessions = new Map<string, Session>();
type Session = { identifier?: string; challenge?: string; error?: { code: string; message: string } };
Bun.serve({
port: 3000,
routes: {
// the home page: the identifier when signed in, the sign-in form otherwise
'/': {
GET: (req) => {
const session = getSession(req);
if (session.identifier) return html(`Signed in as ${session.identifier}`);
const error = session.error?.message ?? '';
delete session.error;
return html(`
<form action="/login" method="post">
<input name="identifier" placeholder="you@example.com">
<button>Sign in with triauth</button>
<p style="color: red">${error}</p>
</form>`);
}
},
// the sign-in form posts here
'/login': {
POST: async (req) => {
// make sure the sign-in is from this site's own pages
const site = req.headers.get('sec-fetch-site');
if (site && site !== 'same-origin') return new Response('Forbidden', { status: 403 });
const session = getSession(req);
const form = await req.formData();
const identifier = String(form.get('identifier') ?? '');
// 1. request
const result = await Triauth.authenticate({ identifier, callbackUrl });
// 2. redirect, when the result holds a challenge
if ('challenge' in result) {
session.challenge = result.challenge; // kept on the server, where the user cannot change it
return redirect(result.redirectUrl);
}
// an invalid identifier, or a domain that is not set up for triauth
if ('error' in result) session.error = result.error;
return redirect('/');
}
},
// 3. verify: the authenticator sends the browser back here, with the response in the query string
'/callback': {
GET: async (req) => {
const session = getSession(req);
const response = new URL(req.url).searchParams.get('response') ?? '';
const result = await Triauth.authenticate({ challenge: session.challenge ?? '', response });
delete session.challenge; // a challenge is good for one attempt only
if ('authenticated' in result) session.identifier = result.identifier;
else if ('error' in result) session.error = result.error; // denied, expired, or not verified
return redirect('/'); // always redirect, so that the response does not stay in the address bar
}
}
},
fetch: () => new Response('Not found', { status: 404 })
});
console.log('Listening on http://localhost:3000');
// finds the session by its sid cookie, or starts a new one
function getSession(req: BunRequest): Session {
const id = req.cookies.get('sid');
const session = id && sessions.get(id);
if (session) return session;
const newId = crypto.randomUUID();
req.cookies.set('sid', newId, { httpOnly: true, sameSite: 'lax' }); // Bun adds the cookie to the response
const fresh: Session = {};
sessions.set(newId, fresh);
return fresh;
}
function html(body: string) {
return new Response(body, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
function redirect(location: string) {
return new Response(null, { status: 302, headers: { Location: location } });
}Troubleshooting
A profile value with accented letters is missing
Bun's node:dns module hands TXT values to the library already decoded, and delivers a record longer than 255 bytes as two records. The system resolver then disagrees with the two DNS-over-HTTPS resolvers, and the multi-resolver drops the value. Keys, the sign-in address, and group records are plain ASCII, so sign-in is not affected. To read such values, leave the system resolver out:
Triauth.config.resolver = new Triauth.Resolvers.MultiResolver([
new Triauth.Resolvers.Cloudflare({ retries: 1, timeout: 4e3 }),
new Triauth.Resolvers.Google({ retries: 1, timeout: 4e3 })
], { maxFailures: 0, timeout: 8.5e3 });Both resolvers must then agree on every record, which is also how the library works in a browser, where the system resolver is unavailable.
Next steps
- Read The sign-in flow for what the result object contains and what to store from it.
- Read Sessions and sign-out to confirm during the session that the device is still trusted, with
Triauth.checkandTriauth.ping. - Read Configuration for the DNS resolvers, the DNSSEC flag, and caching on a busy server.
- See triauth for Node.js for the same server on Node.js, and triauth for Express for the sign-in with Express.
- The triauth-js README has the full API, including
Triauth.whoisto look up an identity, signatures, stamps, and attestations. - The Playground lets you try the protocol in your browser before you write code.
All logos and trademarks belong to their respective owners and do not imply affiliation or endorsement of the organization or product by triauth, nor vice versa. The content provided on this page may include information from third-party sources. We do not assume any responsibility for the completeness, correctness, or applicability of the information. You should independently verify any details before relying on them.