Authentication
FastSchema uses JWT (JSON Web Token) for authentication. The server generates tokens upon login and returns them to the client.
FastSchema uses JWT (JSON Web Token) for authentication. The server generates tokens upon login and returns them to the client. Clients must store and send tokens in every subsequent request.
Public Resources
Every FastSchema resource is protected by authentication except:
- Resources marked as
public(set in resource registration). - Resources granted to the
guestrole (which has no permissions by default).
Authentication Endpoints
Local Authentication
Login (Username/Email + Password)
Request:
POST /api/auth/local/login HTTP/1.1
Content-Type: application/json
{
"login": "admin",
"password": "your_password"
}Response (Success):
{
"token": "<access_token_jwt>",
"expires": "2024-07-01T20:21:10Z",
"refresh_token": "<refresh_token>",
"refresh_token_expires": "2024-07-08T20:21:10Z"
}token: JWT access token.expires: Access token expiration time.refresh_token: Present only ifAUTH_ENABLE_REFRESH_TOKEN=true.refresh_token_expires: Present only if refresh tokens enabled.
Register
Request:
POST /api/auth/local/register HTTP/1.1
Content-Type: application/json
{
"username": "newuser",
"email": "newuser@example.com",
"password": "secure_password",
"password_confirmation": "secure_password"
}username, first_name and last_name are optional. password_confirmation is required and must match password.
A missing email, password or confirmation returns 422; mismatching passwords return 400; an email that is already taken returns 400.
Response (Success):
{
"data": {
"activation": "manual"
}
}activation echoes the configured activation method:
auto: the account is active immediately and can log in.manual: the account stays inactive until an administrator activates it.email: an activation link is emailed to the user. The activation email is sent in the background after the account is created, so a mail delivery problem is logged on the server but does not fail the registration.
Activate Account
Request:
POST /api/auth/local/activate HTTP/1.1
Content-Type: application/json
{
"token": "<activation_token_from_email>"
}Response:
{
"message": "Account activated successfully"
}Send Activation Email
Request:
POST /api/auth/local/activate/send HTTP/1.1
Content-Type: application/json
{
"email": "user@example.com"
}Response:
{
"message": "Activation email sent"
}Password Recovery Request
Request:
POST /api/auth/local/recover HTTP/1.1
Content-Type: application/json
{
"email": "user@example.com"
}Response:
{
"message": "Recovery email sent"
}Check Recovery Token
Request:
POST /api/auth/local/recover/check HTTP/1.1
Content-Type: application/json
{
"token": "<recovery_token_from_email>"
}Response:
{
"valid": true
}Reset Password
Request:
POST /api/auth/local/recover/reset HTTP/1.1
Content-Type: application/json
{
"token": "<recovery_token_from_email>",
"password": "new_password",
"password_confirmation": "new_password"
}password_confirmation is required and must match password. OTP-based flows send session_id instead of token.
Response:
{
"data": true
}Change Email
Starts an authenticated email change. The request re-authenticates the caller with the current password, validates the new address (format, registration policy, and uniqueness), and creates a single-use pending change. It then sends two emails: a notification to the current address and a confirmation link to the new address. The account email is not changed until the new address is confirmed.
Email change is only available for local (password) accounts.
Request:
POST /api/auth/local/email/change HTTP/1.1
Content-Type: application/json
Authorization: Bearer <access_token>
{
"new_email": "new@example.com",
"current_password": "your_current_password"
}Response:
{
"message": "A confirmation link has been sent to your new email address"
}Confirm Email Change
Confirms a pending email change using the single-use, time-limited (24 hours) token delivered to the new address. On success, the account email is committed to the new value.
Request:
POST /api/auth/local/email/confirm HTTP/1.1
Content-Type: application/json
{
"token": "<token_from_new_email>"
}Response:
{
"message": "Your email address has been updated"
}Confirmation link
The confirmation link base defaults to <base_url>/auth/local/email/confirm. To point it at a custom page (for example, a dashboard route that calls the confirm endpoint), set email_change_url in the local provider configuration.
Session Management
Get Current User
Request:
GET /api/auth/me HTTP/1.1
Authorization: Bearer <access_token>Response:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"login": "admin",
"email": "admin@example.com",
"activated": true,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}Refresh Access Token
Request:
POST /api/auth/token/refresh HTTP/1.1
Content-Type: application/json
{
"refresh_token": "<refresh_token>"
}Response:
{
"token": "<new_access_token>",
"expires": "2024-07-01T20:21:10Z",
"refresh_token": "<new_refresh_token>",
"refresh_token_expires": "2024-07-08T20:21:10Z"
}Required: AUTH_ENABLE_REFRESH_TOKEN=true
Logout
Request:
POST /api/auth/logout HTTP/1.1
Authorization: Bearer <access_token>Response:
{
"message": "Logged out successfully"
}Passwordless Authentication (OTP)
Request OTP Code
Request:
POST /api/auth/otp/request HTTP/1.1
Content-Type: application/json
{
"email": "user@example.com"
}Response:
{
"message": "OTP sent to email",
"expires_in": 300
}expires_in: OTP expiration in seconds.
Verify OTP Code
Request:
POST /api/auth/otp/verify HTTP/1.1
Content-Type: application/json
{
"email": "user@example.com",
"code": "123456"
}Response (Success):
{
"token": "<access_token>",
"expires": "2024-07-01T20:21:10Z",
"refresh_token": "<refresh_token>",
"refresh_token_expires": "2024-07-08T20:21:10Z"
}Required: AUTH_OTP_ENABLED=true
Registration Policy
FastSchema can apply an opt-in, built-in validation policy to self-service signups. The policy runs on both local email/password registration and OAuth/social signup, just before the user row is created. It does not apply to admin-created users (content API). By default (no policy configured) nothing is blocked.
The policy supports:
| Field | Description |
|---|---|
allowed_email_domains | Allowlist. When non-empty, only emails on these domains may register. |
blocked_email_domains | Denylist of email domains to reject. |
reserved_usernames | Usernames that may not be registered (e.g. admin, root, system). |
normalize_email | Lowercases the email domain and converts IDN domains to punycode. |
Configure via environment variables
Each variable is a comma-separated list. Setting any of them enables the policy with normalize_email turned on.
| Variable | Description |
|---|---|
AUTH_REG_ALLOWED_DOMAINS | Allowed email domains (allowlist). |
AUTH_REG_BLOCKED_DOMAINS | Blocked email domains (denylist). |
AUTH_REG_RESERVED_USERNAMES | Reserved usernames. |
AUTH_REG_ALLOWED_DOMAINS=example.com,acme.io
AUTH_REG_RESERVED_USERNAMES=admin,root,systemConfigure programmatically
When embedding FastSchema as a Go framework, set the policy on AuthConfig:
app, _ := fastschema.New(&fs.Config{
AuthConfig: &fs.AuthConfig{
Registration: &fs.RegistrationPolicy{
AllowedEmailDomains: []string{"example.com"},
ReservedUsernames: []string{"admin", "root"},
NormalizeEmail: true,
},
},
})The built-in policy runs as the first OnPreUserRegister hook, so any custom hooks you register run afterwards on the already-normalized input.
Custom signup rules (OnPreUserRegister)
For anything beyond the basics above — invite-only gating, blocking disposable-email or free-webmail domains, custom username rules — register an OnPreUserRegister hook. The hook fires for both local and OAuth signups, may mutate Email/Username (for example, to normalize them), and returning an error rejects the registration.
app.OnPreUserRegister(func(ctx context.Context, in *fs.RegistrationInput) error {
// Block a disposable-email domain
if strings.HasSuffix(strings.ToLower(in.Email), "@tempmail.example") {
return errors.BadRequest("Disposable email addresses are not allowed")
}
return nil
})RegistrationInput carries the signup data:
| Field | Type | Description |
|---|---|---|
Email | string | Signup email (mutable). |
Username | string | Signup username (mutable). |
Provider | string | local or the OAuth provider name. |
ProviderID | string | OAuth provider subject id (empty for local). |
Profile | map[string]any | Raw provider profile (OAuth only). |
IsOAuth | bool | true for social-login registration. |
See the Hooks reference for more detail, or Plugin Configuration to register the hook from a JS plugin.
Social / OAuth Login
Social providers (GitHub, Google, ...) are configured under AUTH (enabled_providers + per-provider client_id/client_secret). The dashboard renders a button per enabled provider; the enabled set is exposed by GET /api/auth/methods ({ "providers": [...], "local": bool, "otp": bool }, names only, never secrets). providers lists social providers only (the internal local entry is never included); local tells the dashboard whether to show the username/password form.
Flow (browser):
- The browser navigates to
GET /api/auth/{provider}/login. The server signs a short-livedstatecarrier, sets a matching state cookie (CSRF binding), and redirects to the provider. - After consent, the provider redirects to
GET /api/auth/{provider}/callback. The server verifies thestate(and the binding cookie), then resolves or creates the user. - The token is not placed in a cookie by the server. The callback mints a single-use one-time code and redirects the browser to the dashboard login route with the code in the URL fragment (
/dash/login#code=<one_time_code>). - The dashboard reads the code and calls
auth().exchange(code)(POST /api/auth/exchange), receiving the token over a direct request and persisting it through the SDK auth store. Where the token lives (cookie, localStorage, ...) is the SDK's concern, not the server's.
Because the token is delivered through the one-time code rather than a server-set cookie, the storage mechanism stays pluggable on the client. The state cookie set in step 1 is a transient CSRF binding (not the token); it is matched on the callback and lapses by its short TTL.
Federated-only login (disable local login)
Deployments that must rely on an external identity provider can turn off password and OTP login entirely, leaving federated (social/OIDC) providers as the only way in. This lets the organization enforce MFA at the identity provider; a password or an email OTP is a single factor and does not meet that requirement on its own.
Enable it with AUTH_DISABLE_LOCAL_LOGIN=true. When set:
POST /api/auth/local/loginand the CLI local/OTP login endpoints return403.- Local self-service routes (
register,activate,recover,recover/reset, ...) are not registered. - OTP login is turned off as well, even if
AUTH_OTP_ENABLED=true. GET /api/auth/methodsreturns"local": falseand"otp": false, so the dashboard shows social buttons only.- The local provider stays available internally for email-change and admin password reset; only the login and self-service surfaces are gated.
Bootstrapping the admin account
With local login disabled there is no password root that could sign in, so the first administrator is bootstrapped from an allowlist:
AUTH_DISABLE_LOCAL_LOGIN=true
AUTH_ADMIN_EMAILS=admin@acme.io,ops@acme.io- On a fresh install the setup step seeds the built-in roles without creating a password account.
- The first federated login whose (provider-verified) email matches
AUTH_ADMIN_EMAILSis granted the admin role. Subsequent logins for those emails keep their existing role. - Only enable this with providers that return verified emails (for example Google or GitHub), since the grant trusts the provider-supplied address.
Safety
- At least one social provider must be configured. If
AUTH_DISABLE_LOCAL_LOGIN=trueand no federated provider is enabled, the server refuses to start (there would be no way to log in). - Break-glass: restart with
AUTH_DISABLE_LOCAL_LOGIN=falseto temporarily restore password login (for example to reset an admin password), then re-enable it. - Enabling this mode locks out any pre-existing local (password) accounts; migrate those users to a federated identity or re-invite them.
| Variable | Default | Description |
|---|---|---|
AUTH_DISABLE_LOCAL_LOGIN | false | Reject password + OTP login; expose social providers only. |
AUTH_ADMIN_EMAILS | (empty) | Comma-separated emails granted the admin role on first federated login. |
CLI / Native-App Login (RFC 8252)
A command-line tool or native desktop app can sign a user in without ever handling the user's password or seeing the JWT in the browser. The user logs in through the normal dash browser page by any enabled method (local, social, or OTP), and the resulting credential is delivered to the app's local loopback listener via a single-use one-time code, following the OAuth 2.0 for Native Apps pattern (RFC 8252).
This feature is opt-in and disabled by default. Enable it with AUTH_CLI_LOGIN_ENABLED=true.
Why a one-time code
The token is minted server-side and stashed behind a short-lived, single-use one-time code. Only the code travels through the browser redirect; the loopback listener then exchanges the code for the token over a direct server-to-server request. The token itself never appears in any browser URL, response, or cookie during this flow. A PKCE (S256) challenge binds the exchange to the process that started it, defeating code interception.
Flow
- The native app starts a loopback HTTP listener on
127.0.0.1(any free port) and generates a PKCEcode_verifier. - App ->
POST /api/auth/cli/initiatewith the loopbackredirect_uri, an opaquecorrelationvalue, andcode_challenge = base64url(sha256(code_verifier)). The server validates the redirect target and returns a signedcarrierplus anauthorize_url(/dash/login?cli=<carrier>). - The app opens
authorize_urlin the user's browser. The dash renders the enabled login methods and the user signs in by any of them. - On success the server mints the token, stores it under a one-time code, and
302-redirects the browser to the loopbackredirect_uri?code=<one_time_code>&state=<correlation>. - The loopback listener ->
POST /api/auth/exchangewith{ code, code_verifier }. The server verifies PKCE, atomically consumes the code (single use), and returns the JWT exactly once.
Initiate
Request:
POST /api/auth/cli/initiate HTTP/1.1
Content-Type: application/json
{
"redirect_uri": "http://127.0.0.1:54321/callback",
"correlation": "a-random-opaque-value",
"code_challenge": "BASE64URL_SHA256_OF_VERIFIER"
}code_challenge is required (PKCE S256): native-app loopback redirects are interceptable by other local processes, so every code is bound to a verifier only the initiating process holds.
Response:
{
"carrier": "<signed-opaque-carrier>",
"authorize_url": "/dash/login?cli=<signed-opaque-carrier>"
}Exchange
Request:
POST /api/auth/exchange HTTP/1.1
Content-Type: application/json
{
"code": "<one_time_code>",
"code_verifier": "<original_code_verifier>"
}Response (Success):
{
"token": "<access_token>",
"expires": "2024-07-01T20:21:10Z",
"refresh_token": "<refresh_token>",
"refresh_token_expires": "2024-07-08T20:21:10Z"
}The code is single-use and short-lived (60 seconds); a second exchange, an expired code, or a mismatched code_verifier returns 401.
Redirect target rules
redirect_uri is strictly validated before any code is issued, so the endpoint can never be used as an open redirector:
- Loopback hosts (
127.0.0.1,::1,localhost) are always allowed on any port, overhttporhttps. This is the normal case for native apps. - Non-loopback hosts are allowed only over
httpsand only when listed inAUTH_CLI_ALLOWED_REDIRECT_HOSTS(exact host match, no wildcards).
Configuration
| Variable | Default | Description |
|---|---|---|
AUTH_CLI_LOGIN_ENABLED | false | Master switch for the feature. When false, all cli endpoints return 403. |
AUTH_CLI_ALLOWED_REDIRECT_HOSTS | (empty) | Comma-separated allowlist of non-loopback https redirect hosts. |
Programmatically (Go framework):
app, _ := fastschema.New(&fs.Config{
AuthConfig: &fs.AuthConfig{
CLILogin: &fs.CLILoginConfig{
Enabled: true,
AllowedRedirectHosts: []string{"app.example.com"},
},
},
})Security notes
- Disabled by default; only loopback redirects are accepted unless you explicitly allowlist https hosts.
- One-time codes are single-use, short-lived (60s), and high-entropy; PKCE S256 (required) binds the exchange to the initiating process.
- An exchange attempt consumes the code even when the PKCE verifier is wrong (fail-closed: this prevents brute-forcing the verifier on a live code). If an exchange fails, restart the flow.
- The token never touches the browser in this flow - it is delivered only over the server-to-server exchange.
- The browser-mediated step is bound to the browser that started it (a per-session state cookie is matched on the provider callback), closing login CSRF / session fixation.
- The one-time code store is in-process (single instance). For a multi-node deployment behind a load balancer, the exchange must reach the same instance that minted the code; a shared store is a planned follow-up.
Token Usage
Clients must send the access token in every authenticated request. Two methods:
Method 1: Authorization Header
GET /api/content/users/550e8400-e29b-41d4-a716-446655440000 HTTP/1.1
Authorization: Bearer <access_token>Method 2: Cookie
GET /api/content/users/550e8400-e29b-41d4-a716-446655440000 HTTP/1.1
Cookie: token=<access_token>Configuration
Environment Variables
| Variable | Default | Description |
|---|---|---|
AUTH_ENABLE_REFRESH_TOKEN | false | Enable refresh token support |
AUTH_REFRESH_TOKEN_LIFETIME | 604800 | Refresh token lifetime in seconds (7 days) |
AUTH_ACCESS_TOKEN_LIFETIME | 604800 | Access token lifetime in seconds (7 days) or 15 minutes when refresh enabled |
AUTH_OTP_ENABLED | false | Enable OTP passwordless authentication |
AUTH_OTP_LENGTH | 6 | OTP code length |
AUTH_OTP_EXPIRATION | 300 | OTP expiration in seconds (5 minutes) |
AUTH_OTP_MAX_ATTEMPTS | 3 | Max verification attempts before OTP expires |
AUTH_REG_ALLOWED_DOMAINS | (empty) | Comma-separated allowlist of email domains for signup (see Registration Policy) |
AUTH_REG_BLOCKED_DOMAINS | (empty) | Comma-separated denylist of email domains for signup |
AUTH_REG_RESERVED_USERNAMES | (empty) | Comma-separated list of usernames that cannot be registered |
AUTH_CLI_LOGIN_ENABLED | false | Enable CLI / native-app login (see CLI / Native-App Login) |
AUTH_CLI_ALLOWED_REDIRECT_HOSTS | (empty) | Comma-separated allowlist of extra non-loopback https redirect hosts |
Email Templates
Activation and recovery emails are customizable via the fs.EmailTemplates configuration. See Configuration for details.
Error Handling
Authentication errors return structured error responses. See Error Codes Reference for details.
Example error response:
{
"code": "401",
"message": "Unauthorized",
"data": {
"error": "invalid_credentials"
}
}Token Expiration
- Access tokens expire after the configured lifetime (default: 7 days).
- Refresh tokens expire after their configured lifetime (default: 7 days).
- The server returns
401 Unauthorizedif a token is expired. - Use
/api/auth/token/refreshto obtain a new access token (requires refresh token support enabled).
Security Notes
- Store access tokens securely (in-memory or secure storage, never localStorage for sensitive data).
- Refresh tokens should be stored securely and sent only with
/api/auth/token/refreshrequests. - Always use HTTPS in production.
- Implement CSRF protections for cookie-based token transmission.
- Change
APP_KEYvia environment to invalidate all active tokens (security measure).
See Also
Access Control
FastSchema includes built-in Role-Based Access Control (RBAC) with support for custom rules, allowing precise management of user access to all resources.
List records
FastSchema use RQL to query the records. It supports various query parameters to enable you to filter, sort, and paginate the records.