Tutorial 7: Build an authentication system
Objective: accounts with sign-up, sign-in and sign-out; a page only signed-in members can open; an admin-only page; and a header that changes with the visitor.
Prerequisites: Tutorial 5.
What you configure: a user table, auth in app.iwc, three APIs and the screens. What IWC does for you: password hashing (PBKDF2-SHA256, 600k iterations), sessions in HttpOnly cookies, CSRF protection, rate-limited sign-in, redirects to the login page with a return address, and never accepting a role from a request.
auth/
├── api/
│ ├── login.api.iwc
│ ├── me.api.iwc
│ └── register.api.iwc
├── components/
│ └── header.component.iwc
├── database/
│ └── user.schema.iwc
├── layouts/
│ └── main.layout.iwc
├── styles/
│ └── main.css
├── tests/
│ └── auth.test.iwc
├── ui/
│ ├── account.ui.iwc
│ ├── admin.ui.iwc
│ ├── home.ui.iwc
│ ├── login.ui.iwc
│ ├── not-found.ui.iwc
│ └── register.ui.iwc
├── app.iwc
└── package.iwcStep 1: The user table
{
"name": "user",
"fields": {
"name": { "type": "string", "required": true, "maxLength": 80 },
"email": { "type": "email", "required": true, "unique": true },
"password": { "type": "password", "required": true },
"role": { "type": "enum", "values": ["member", "admin"], "default": "member", "required": true }
},
"access": { "read": ["admin", "self"] },
"seed": [
{ "id": "u-admin", "name": "Ada Admin", "email": "admin@example.com", "password": "admin-pass-123", "role": "admin" },
{ "id": "u-member", "name": "Max Member", "email": "max@example.com", "password": "member-pass-123", "role": "member" }
]
}passwordfields are hashed on every write — seeds included — and can never be selected for the browser or an API.roleis anenum; registration always gives thedefaultRole.access.read: ["admin", "self"]lets admins read every user and everyone else only their own row (the default withoutaccessis"self"alone).- The seed rows give you an admin and a member to sign in with during development.
Step 2: Turn accounts on
{
"name": "Members",
"iwc": 1,
"theme": {
"tokens": {
"color-primary": "#5b4cf5",
"color-on-primary": "#ffffff",
"color-muted": "#5d6073",
"color-surface": "#f6f4ff",
"color-border": "#e6e6ef",
"color-danger": "#c0262d",
"radius": "12px",
"font-body": "system-ui, sans-serif"
}
},
"styles": ["styles/main.css"],
"head": { "title": "%s · Members" },
"auth": {
"user": "user.schema.iwc",
"identity": "email",
"defaultRole": "member",
"loginPage": "/login",
"session": { "ttl": "7d" }
},
"notFound": "not-found.ui.iwc"
}auth key | Meaning |
|---|---|
user | The user schema |
identity | The field people sign in with |
defaultRole | The role new accounts get |
loginPage | Where protected screens send signed-out visitors (with ?next= so they come back) |
session.ttl | How long a browser session lasts |
Step 3: The APIs
{
"name": "register",
"method": "POST",
"path": "/auth/register",
"operation": "auth.register",
"request": {
"payload": {
"name": { "type": "string", "required": true, "minLength": 2, "maxLength": 80 },
"email": { "type": "email", "required": true },
"password": { "type": "string", "required": true, "minLength": 8, "maxLength": 200 }
}
},
"response": { "status": 201, "fields": { "success": "boolean", "user": "object" } },
"security": { "rateLimit": true }
}{
"name": "login",
"method": "POST",
"path": "/auth/login",
"operation": "auth.login",
"request": {
"payload": {
"email": { "type": "email", "required": true },
"password": { "type": "string", "required": true, "maxLength": 200 }
}
},
"response": { "fields": { "success": "boolean", "user": "object" } }
}{ "name": "me", "method": "GET", "path": "/auth/me", "operation": "auth.me" }Signing out needs no API: a button with "action": "logout" posts to IWC's built-in endpoint.
Step 4: Sign-in and registration screens
{
"name": "Login",
"route": "/login",
"layout": "main.layout.iwc",
"title": "Sign in",
"sections": [
{
"type": "column",
"className": "auth-card",
"layout": { "direction": "column", "gap": "14px" },
"elements": [
{ "type": "text", "variant": "h1", "content": "Sign in" },
{
"type": "form",
"inputs": [
{ "name": "email", "type": "email", "label": "Email", "required": true, "autocomplete": "email" },
{ "name": "password", "type": "password", "label": "Password", "required": true, "autocomplete": "current-password" }
],
"submit": { "api": "login.api.iwc", "label": "Sign in", "success": { "navigate": "/account" } }
},
{ "type": "link", "label": "No account yet? Create one", "target": "/register" }
]
}
]
}{
"name": "Register",
"route": "/register",
"layout": "main.layout.iwc",
"title": "Create an account",
"sections": [
{
"type": "column",
"className": "auth-card",
"layout": { "direction": "column", "gap": "14px" },
"elements": [
{ "type": "text", "variant": "h1", "content": "Create an account" },
{
"type": "form",
"inputs": [
{ "name": "name", "type": "text", "label": "Name", "required": true, "autocomplete": "name" },
{ "name": "email", "type": "email", "label": "Email", "required": true, "autocomplete": "email" },
{ "name": "password", "type": "password", "label": "Password", "required": true, "autocomplete": "new-password", "hint": "At least 8 characters." }
],
"submit": { "api": "register.api.iwc", "label": "Create account", "success": { "navigate": "/account" } }
},
{ "type": "link", "label": "Already a member? Sign in", "target": "/login" }
]
}
]
}Step 5: A header that knows who is visiting
{
"name": "Header",
"root": {
"type": "navbar",
"className": "site-header",
"elements": [
{ "type": "link", "label": "Members", "target": "/", "className": "brand" },
{
"type": "row",
"className": "account-actions",
"layout": { "align": "center", "gap": "12px" },
"elements": [
{ "type": "link", "label": "Sign in", "target": "/login", "visible": { "auth": "signed-out" } },
{ "type": "button", "label": "Create account", "action": "navigate", "target": "/register", "visible": { "auth": "signed-out" } },
{ "type": "link", "label": "Admin", "target": "/admin", "visible": { "roles": ["admin"] } },
{ "type": "link", "label": "My account", "target": "/account", "visible": { "auth": "signed-in" } },
{ "type": "button", "label": "Sign out", "variant": "ghost", "action": "logout", "target": "/", "visible": { "auth": "signed-in" } }
]
}
]
}
}{
"name": "MainLayout",
"slots": { "header": "header.component.iwc", "content": "PAGE_CONTENT" },
"structure": { "header": { "position": "sticky" } }
}visible is decided on the server: a signed-out visitor's HTML never contains the Admin link. {"roles": ["admin"]} shows an element only to admins.
Step 6: Protected pages
{
"name": "Account",
"route": "/account",
"layout": "main.layout.iwc",
"auth": "required",
"title": "My account",
"sections": [
{
"type": "section",
"className": "page",
"data": { "source": "USER", "mode": "single" },
"layout": { "direction": "column", "gap": "8px" },
"elements": [
{ "type": "text", "variant": "h1", "content": "My account" },
{ "type": "text", "bind": "name", "prefix": "Name: " },
{ "type": "text", "bind": "email", "prefix": "Email: " },
{ "type": "text", "bind": "role", "prefix": "Role: " }
]
}
]
}"auth": "required" redirects signed-out visitors to /login?next=/account. The USER data source is the signed-in user (public fields only).
{
"name": "Admin",
"route": "/admin",
"layout": "main.layout.iwc",
"auth": "required",
"roles": ["admin"],
"title": "Admin",
"sections": [
{
"type": "section",
"className": "page",
"layout": { "direction": "column", "gap": "16px" },
"elements": [
{ "type": "text", "variant": "h1", "content": "Members (admins only)" },
{
"type": "table",
"data": { "source": "DATABASE.users", "columns": ["name", "email", "role", "createdAt"], "sort": { "field": "createdAt", "order": "desc" } },
"cells": [
{ "field": "name", "label": "Name" },
{ "field": "email", "label": "Email" },
{ "field": "role", "label": "Role" },
{ "field": "createdAt", "label": "Joined", "format": "date" }
]
}
]
}
]
}"roles": ["admin"] answers members with 403.
Step 7: Test it
{
"name": "auth",
"cases": [
{ "name": "visitors see the sign-in links", "request": { "path": "/" }, "expect": { "status": 200, "contains": ["Sign in", "Create account"], "excludes": ["Sign out"] } },
{ "name": "registration creates a member and signs them in", "request": { "method": "POST", "path": "/api/auth/register", "body": { "name": "Riya", "email": "riya@example.com", "password": "riya-pass-123" } }, "expect": { "status": 201, "body": { "success": true } } },
{ "name": "an email can only register once", "request": { "method": "POST", "path": "/api/auth/register", "body": { "name": "Riya", "email": "riya@example.com", "password": "riya-pass-123" } }, "expect": { "status": 409 } },
{ "name": "nobody can register themselves as admin", "request": { "method": "POST", "path": "/api/auth/register", "body": { "name": "Eve", "email": "eve@example.com", "password": "eve-pass-1234", "role": "admin" } }, "expect": { "status": 422 } },
{ "name": "a wrong password is refused", "request": { "method": "POST", "path": "/api/auth/login", "body": { "email": "max@example.com", "password": "wrong-password" } }, "expect": { "status": 401 } },
{ "name": "the account page needs sign-in", "request": { "path": "/account" }, "expect": { "status": 303 } },
{ "name": "a member sees their account", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "path": "/account" }, "expect": { "status": 200, "contains": ["Max Member", "Role: member", "Sign out"] } },
{ "name": "members cannot open the admin page", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "path": "/admin" }, "expect": { "status": 403 } },
{ "name": "admins can", "as": { "email": "admin@example.com", "password": "admin-pass-123" }, "request": { "path": "/admin" }, "expect": { "status": 200, "contains": ["Members (admins only)", "max@example.com"], "excludes": ["member-pass-123"] } },
{ "name": "me returns the signed-in user without the password", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "path": "/api/auth/me" }, "expect": { "status": 200, "contains": ["max@example.com", "Max Member"], "excludes": ["member-pass-123", "pbkdf2"] } }
]
}A case with as signs in as that user first. Run them:
iwc test && iwc security ✓ auth › visitors see the sign-in links
✓ auth › registration creates a member and signs them in
✓ auth › an email can only register once
✓ auth › nobody can register themselves as admin
✓ auth › a wrong password is refused
✓ auth › the account page needs sign-in
✓ auth › a member sees their account
✓ auth › members cannot open the admin page
✓ auth › admins can
✓ auth › me returns the signed-in user without the password
10 passed, 0 failed
✓ no security findingsNote the fourth case: a role in the registration body is rejected with 422 because the API doesn't declare it, and roles are never taken from requests anyway.
Going further
- Mobile apps and scripts sign in with
"session": "token"and sendAuthorization: Bearer …; see Sessions. - Two-factor sign-in, social sign-in, password reset and email verification are built in; see Authentication.
- Permissions and scopes ("a store manager sees only their stores") are in Access control.
Troubleshooting
| Problem | Fix |
|---|---|
"as" needs an api with operation auth.login | Tests sign in through your login API; add it. |
| Sign-in says "Invalid email or password" for a seeded user | Seeds are inserted only into empty tables. Delete .iwc/ to reseed. |
| 429 after several attempts | Sign-in is rate-limited per IP and per account (10/minute by default). |
Next: Create a CRUD application.
Source: apps/marketing/content/docs/tutorials/authentication.md