Skip to content
ONIWC
iwc 0.1.0

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.

Project files
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.iwc

Step 1: The user table

database/user.schema.iwc
{
  "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" }
  ]
}
  • password fields are hashed on every write — seeds included — and can never be selected for the browser or an API.
  • role is an enum; registration always gives the defaultRole.
  • access.read: ["admin", "self"] lets admins read every user and everyone else only their own row (the default without access is "self" alone).
  • The seed rows give you an admin and a member to sign in with during development.

Step 2: Turn accounts on

app.iwc
{
  "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 keyMeaning
userThe user schema
identityThe field people sign in with
defaultRoleThe role new accounts get
loginPageWhere protected screens send signed-out visitors (with ?next= so they come back)
session.ttlHow long a browser session lasts

Step 3: The APIs

api/register.api.iwc
{
  "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 }
}
api/login.api.iwc
{
  "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" } }
}
api/me.api.iwc
{ "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

ui/login.ui.iwc
{
  "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" }
      ]
    }
  ]
}
ui/register.ui.iwc
{
  "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

components/header.component.iwc
{
  "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" } }
        ]
      }
    ]
  }
}
layouts/main.layout.iwc
{
  "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

ui/account.ui.iwc
{
  "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).

ui/admin.ui.iwc
{
  "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

tests/auth.test.iwc
{
  "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:

Terminal
iwc test && iwc security
Output
  ✓ 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 findings

Note 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 send Authorization: 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

ProblemFix
"as" needs an api with operation auth.loginTests sign in through your login API; add it.
Sign-in says "Invalid email or password" for a seeded userSeeds are inserted only into empty tables. Delete .iwc/ to reseed.
429 after several attemptsSign-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