IMPORTANT: this guide isn't finished yet, but you might still find it useful. It doesn't yet include how to persist users across server restarts (that happen on each
git push)
This guide will take you through creating a secure user login system on your website. It will also help you with some techniques to create more modular code. It includes:
In this section you'll get user logins working on your website within minutes. Use later sections to understand how it works, and how to customise it for your needs.
Make an HTML file which is either:
public folder; orusers.html in the root of your repo and imported to your server with
import userPage from './users.html'Bun.serve()'s routes object: '/users': userPage,Stick this in the HTML file:
<!doctype html>
<title>User accounts</title>
<style>
.hidden { display: none; }
fieldset { max-width: 500px; }
label { display: block; margin: 10px auto; }
</style>
<div id="loginOrRegister" class="hidden loggedOut">
<fieldset>
<legend>Log in</legend>
<label>Email: <input type="email" name="email" placeholder="eg bob@example.com"></label>
<label>Password: <input type="password" name="password" placeholder="min 8 chars"></label>
<p><button id="login">Log in</button> or <button id="register">Register</button></p>
</fieldset>
</div>
<p>You are: <span id="user">loading user...</span></p>
<form id="logout" method="post" action="/logout" class="hidden loggedIn">
<button>Log out</button>
</form>
<p class="loggedIn">
... show something here only if logged in
</p>
<script>
getUser() // on load
// show if a user is logged in
async function getUser () {
let user = await (await fetch('/user')).json()
document.querySelector('#user').innerText = user ? user.email : 'not logged in'
document.querySelectorAll('.loggedIn').forEach(el => el.classList.toggle('hidden', !user))
document.querySelectorAll('.loggedOut').forEach(el => el.classList.toggle('hidden', !!user))
}
document.querySelector('#login').onclick = () => loginOrRegister('/login')
document.querySelector('#register').onclick = () => loginOrRegister('/register')
async function loginOrRegister (path) {
let { success, error } = await (await fetch(path, {
method: 'post',
body: JSON.stringify({
email: document.querySelector('[name=email]').value,
password: document.querySelector('[name=password]').value
})
})).json()
if (error) alert(error)
else if (success) getUser()
}
</script>
Then, in your server.js:
import { users, userRoutes } from './users.js'
let server = Bun.serve({
routes: {
// ... your existing routes here
'/users': () => Response.json(users),
...userRoutes()
}
})
console.log(`Server at ${server.url}`)
Finally, add a file to the root of your repo called users.js:
// NOTE: will switch to PostgreSQL at a later date
let sessions = {} // sessionKey: email
export let users = {} // email: { password, ... }
function startSession (req, email) {
let sessionId = crypto.randomUUID()
sessions[sessionId] = email
req.cookies.set('session', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'strict',
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365) // 365 days
})
}
export function userRoutes () {
return {
// get a user from their session, if any
'/user': (req) => {
let sessionKey = req.cookies.get('session')
let email = sessions[sessionKey]
if (!email) return Response.json(null)
let { hash, ...user } = { ...users[email], email } // add email, remove pw hash from obj
return Response.json(user)
},
// try to register a user
'/register': {
POST: async (req) => {
let { email, password } = await req.json()
if (!email || !password) return Response.json({ error: 'No email or password provided' })
if (!/@/.test(email)) return Response.json({ error: 'Not a valid email' })
if (users[email]) return Response.json({ error: 'Email already registered' })
users[email] = { hash: await Bun.password.hash(password) }
startSession(req, email)
return Response.json({ success: true })
}
},
// try to log in a user
'/login': {
POST: async (req) => {
let { email, password } = await req.json()
let user = users[email]
if (!user) return Response.json({ error: 'Email not registered' })
if (!await Bun.password.verify(password, user.hash)) return Response.json({ error: 'Incorrect password' })
startSession(req, email)
return Response.json({ success: true })
}
},
// log a user out
'/logout': {
POST: async (req) => {
req.cookies.delete('session')
return Response.redirect('/')
}
}
}
}
Now just restart your server and go to the URL for the HTML page you added. Try:
If something doesn't work, check you followed the instructions correctly.
HTTP requests and response have a body as well as headers. Requests from clients can include a cookie header, and responses from servers can include a set-cookie header.
Cookies are stored in the browser, separately for each website you visit. If the browser has a cookie, it sends it to the server in every request automatically via the cookie header, which server-side code can then read.
A server can respond to any request with a set-cookie header including a cookie's name and value. When the client receives this response the cookie is stored in the browser.
So, it's possible to read and write cookies purely server-side, which is what happens with session cookies. Whilst they're stored in a user's browser, you can even create the cookies so that client-side JavaScript can't even read them. In fact, you should, for security reasons, by setting the httpOnly property on session cookies.
The value of the session cookie shouldn't be the user's id, email, name, password or any other such information. Instead, it should be a cryptographically secure random token which the server somehow associates with a particular user.
Because the session cookie is sent as a header in all HTTP requests, server-side code handling any route can use it to see if it belongs to a particular user, and if so, take actions for that user.
You should never store plain text passwords on your server. Instead, its important to store hashes of passwords. These hashes are a seemingly random string of characters which somehow represent the user's actual password. Later, when the user tries to login, you can compare the hash of their password attempt with the stored hash. If they match, then the user provided the correct password.
The reason not to store plain text passwords is in case you're hacked or have some security vulnerability elsewhere. If someone other than the user got access to their plain text password, the user might not be very happy. By storing just a hash of a password, it shouldn't be possible to find out what the user's actual password is, without trying a brute force attack.
Note that we hash rather than encrypt passwords. Encryption is two ways - you can encrypt and decrypt, often with the same key. Hashing is one way only - you cannot un-hash a hash.
...userRoutes()Rather than copying and pasting the /login, /register, /logout etc routes in your server.js file inside the routes object, you'll notice just one addition to the routes object:
routes {
// ... other routes here
...userRoutes()
}
userRoutes() is a function that returns an object of routes. When we do ...userRoutes() we are spreading the routes from that object into the main routes object.
By doing it this way, we create more modular code, separating our user routes from other routes. If you start adding lots of routes to your server, you might find it handy to do the same with other routes too.
session:
id) to be of type uuid with gen_random_uuid() as the default valueuserId of type int8user:
id as-isemail of type text and under the settings icon tick 'is unique'hash of type textTBC...