Authorization

How IoTMan acts as an OAuth2 Authorization Server to protect your data endpoints and external services.

This guide explains IoTMan's authorization model and walks through two concrete scenarios: protecting an IoTMan data export endpoint and protecting an external HTTP server (nginx or Caddy) using the same tokens.


IoTMan's authorization model

IoTMan acts as an OAuth2 Authorization Server (AS). It issues signed tokens that protected resources — whether IoTMan's own data API or your own servers — can verify independently.

Three types of principals interact with the system:

PrincipalWho they areHow they authenticate
Workspace membersIoTMan users who manage the workspacePasswordless magic link at IoTMan login
End-usersExternal people who consume protected resourcesPasswordless magic link, via an OAuth2 Authorization Code + PKCE flow
M2M clientsDevices and backend services acting on their ownClient Credentials grant (client ID + secret)

End-users and M2M clients are managed in the Access tab.


What a token carries

IoTMan issues JWT Bearer tokens signed with RS256 (asymmetric key). Any resource server can verify the signature using the public key from the JWKS endpoint — no shared secret required.

ClaimExample valueMeaning
isshttps://iotman.ioIssuer — always IoTMan
subuser-uuid or client-uuidThe end-user or M2M client the token was issued to
audws-slugThe workspace slug that issued the token
scopesensor:read video:goldSpace-separated list of granted permissions
exp1753000000Expiry (Unix timestamp, typically +1 hour)

aud = workspace slug: Every token is bound to its workspace. A resource server validates aud against its known workspace slug to prevent tokens from other workspaces from being accepted — even if they carry identical scope names.

Scopes as the access boundary: Scopes are defined per workspace. Each resource server decides which scope it requires. A token with sensor:read is useless against a server that requires video:gold.

Access token vs ID token: The token above is an access token — it proves authorization. IoTMan does not issue ID tokens, but provides a UserInfo endpoint for retrieving user attributes.


Walkthrough: protecting an IoTMan export endpoint

Scenario: You have a workspace with a temperature sensor output. You want to restrict the data API endpoint so only authorised clients can read it.

1. Define a scope

In Access → Scopes, create:

2. Protect the endpoint

In the Export tab, create or edit your endpoint and set Required scope to sensor:read. Without a valid Bearer token carrying this scope, the endpoint returns 401.

Endpoints without a required scope remain public — existing integrations are not affected.

3a. Grant access to an end-user

In Access → End-users, invite the user:

  1. Enter their email.
  2. Select scope: sensor:read.
  3. Click Invite. The user receives a magic link.

Register a PKCE OAuth2 client (in Access → OAuth2 clients) for the application that will request tokens on the end-user's behalf.

The end-user authenticates via Authorization Code + PKCE and the application exchanges the code for a token:

POST https://iotman.io/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&client_id=<client-id>
&code=<auth-code>
&code_verifier=<pkce-verifier>
&redirect_uri=<redirect-uri>

IoTMan returns a Bearer token in the JSON body. The application stores it and sends it as Authorization: Bearer <token> on subsequent requests.

3a-alt. Grant access via the OTP extension grant (no redirect to IoTMan)

If the third-party app wants to render its own login UI without redirecting the user's browser to IoTMan, use the OTP extension grant. The app collects the user's email, asks IoTMan to email a one-time code, then exchanges that code at /oauth2/token directly. The user never leaves the third-party domain.

POST https://iotman.io/api/auth/send-otp
Content-Type: application/json

{ "email": "user@example.com", "client_id": "<client-id>" }

This emails a six-digit code that names the calling app ("Sign in to <client name> with IoTMan"). The code is bound to the supplied client_id, so a different client cannot redeem it.

POST https://iotman.io/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:iotman:params:oauth:grant-type:otp
&client_id=<client-id>
&email=user@example.com
&code=<six-digit code>

IoTMan returns the same Bearer + refresh shape as authorization_code. The returned scope is the intersection of the user's grants (end_user_access + end_user_iotman_scopes for the client's workspace) and any scope form parameter the app requested. Refresh works exactly as for authorization_code (grant_type=refresh_token).

The client_id must appear on both calls. send-otp stamps the requesting client onto the OTP row; /oauth2/token only redeems rows whose stamped client matches the calling client. If you call send-otp with no client_id and then try to redeem at /oauth2/token, the token endpoint won't find a usable row and returns invalid_grant: "No pending OTP for this email + client_id. Call POST /api/auth/send-otp with the same client_id first." Codes minted by send-otp without client_id are reserved for IoTMan's own dashboard session flow.

The OTP grant requires a registered non-client_credentials client_id.

3b. Grant access to an M2M client

Register a Client Credentials OAuth2 client in Access → OAuth2 clients. Copy the generated secret.

The device requests a token directly:

POST /oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=<client-id>
&client_secret=<secret>
&scope=sensor:read

4. Query the endpoint

GET https://iotman.io/data/<workspace-short-id>/<url-path>
Authorization: Bearer <JWT>

IoTMan validates the JWT: checks signature, aud (workspace UUID), scope, and exp. If all pass, data is returned.


Walkthrough: protecting an external server (nginx / Caddy)

Scenario: You serve video files through nginx. You want to use IoTMan tokens to control access by tier.

1. Define scopes

In Access → Scopes, create:

2. Grant access

Invite end-users in Access → End-users with the appropriate scope, or register a Client Credentials client for M2M access.

Your workspace UUID is shown in Access → Scopes. External servers use this as the expected aud value.

3. Configure nginx

nginx validates JWTs on each request. A minimal configuration using ngx_http_auth_jwt_module:

auth_jwt_jwks_uri https://iotman.io/.well-known/jwks.json;

location /gold/ {
    auth_jwt "gold tier";
    auth_jwt_claim_set $jwt_aud aud;
    auth_jwt_claim_set $jwt_scope scope;

    # Verify audience is the expected workspace
    if ($jwt_aud != "<your-workspace-slug>") { return 401; }
    # Verify scope
    if ($jwt_scope !~ "video:gold") { return 403; }
}

3b. Configure Caddy

video.example.com {
    forward_auth localhost:8181 {
        uri /validate
        copy_headers X-User X-Scope
    }
    file_server
}

The sidecar fetches IoTMan's JWKS and validates aud (workspace UUID) and scope on each request.


Two scope namespaces

IoTMan tokens can carry scopes from two independent namespaces. Both appear as space-separated entries in the scope claim and are validated the same way by resource servers.

Workspace custom scopes are admin-defined, workspace-scoped strings (for example sensor:read, video:gold). They authorize user-defined data and export endpoints. Scope names are chosen by the admin and carry no prefix.

Internal API iotman: scopes are a fixed catalogue reserved for the built-in workspace-management API (/api/workspaces/{id}/...). They follow iotman:<resource>:<verb> — e.g. iotman:endpoints:create, iotman:mqtt-configs:activate, iotman:members:read. The iotman: prefix keeps them unambiguously separate from custom scopes.

The built-in Admin, Editor and Viewer roles automatically grant the corresponding iotman: scopes when a workspace member calls the API with their session cookie. For programmatic callers (PKCE end-users or Client Credentials M2M clients), admins grant specific iotman: scopes explicitly.

Granting iotman: scopes to an end-user (PKCE)

POST /api/workspaces/{ws}/end-users/iotman-scopes
Content-Type: application/json

{ "email": "alice@example.com", "scope": "iotman:endpoints:read" }

The next time Alice signs in to the app, IoTMan asks her to confirm the sign-in with her email — scope choices are not surfaced to end-users since permissions are admin-managed, not user-selected. Her access token will carry iotman:endpoints:read (alongside any custom scopes she was granted) and can call GET /api/workspaces/{ws}/endpoints directly. Internal API calls resolve scopes from the database on every request, so admin grants and revocations take effect immediately without waiting for the token to refresh.

To prevent privilege escalation, the admin granting a scope must themselves hold that scope.

Granting iotman: scopes to an OAuth2 client (client_credentials)

POST /api/workspaces/{ws}/oauth2-clients/{clientId}/iotman-scopes
Content-Type: application/json

{ "scope": "iotman:sensor-data:read" }

A client_credentials token for that client may then request this scope. The previous blanket "any Admin scope" behaviour for audience=iotman-webapp is removed — clients only get what they have been explicitly granted.


UserInfo endpoint

IoTMan exposes a standard OpenID Connect UserInfo endpoint for retrieving profile claims and custom metadata about an authenticated end-user.

GET https://iotman.io/oauth2/userinfo
Authorization: Bearer <access-token>

It returns the user's standard claims plus any entries from their metadata store:

{
  "sub": "<user-uuid>",
  "email": "alice@example.com",
  "ingest_context": { "channels": ["temperature"], "tier": "gold" }
}

Custom metadata keys (such as ingest_context) are set by workspace admins or external workers via the management API. They are merged as top-level fields alongside sub and email.

Device tokens are not supported. Client Credentials tokens have no associated end-user — calling /oauth2/userinfo with one returns 400.


Token metadata API

Workspace admins and external workers can attach arbitrary JSONB metadata to end-users and OAuth2 clients. This metadata is managed via the management API and is distinct from scopes, which control operation-level access.

End-user metadata

GET    /api/workspaces/{ws}/end-users/{email}/metadata
PUT    /api/workspaces/{ws}/end-users/{email}/metadata/{key}
DELETE /api/workspaces/{ws}/end-users/{email}/metadata/{key}

PUT body: { "value": <any JSON> }

The reserved key ingest_context has special behaviour: its value is stamped into sensor_data.metadata at ingestion time whenever that end-user submits data via a PKCE-authenticated endpoint. This captures the user's authorization context as a write-time snapshot — changing it later does not affect existing datapoints.

Client metadata

GET    /api/workspaces/{ws}/clients/{clientId}/metadata
PUT    /api/workspaces/{ws}/clients/{clientId}/metadata/{key}
DELETE /api/workspaces/{ws}/clients/{clientId}/metadata/{key}

Client metadata is not embedded in datapoints — device context is static and can be joined at query time using the oauth2_client_id recorded in sensor_data.metadata.

Token-metadata endpoints require iotman:token-metadata:read for reads and iotman:token-metadata:write for writes. Both are included in the Admin role.


JWKS reference

IoTMan exposes its public signing key at:

GET https://iotman.io/.well-known/jwks.json

Example response:

{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "2026-key-1",
      "n": "...",
      "e": "AQAB"
    }
  ]
}

Most JWT libraries support JWKS-based verification out of the box. Examples:

# Python — PyJWT
from jwt import PyJWKClient, decode
jwks_client = PyJWKClient("https://iotman.io/.well-known/jwks.json")
signing_key = jwks_client.get_signing_key_from_jwt(token)
payload = decode(token, signing_key.key, algorithms=["RS256"],
                 audience="<your-workspace-slug>")
# Verify the required scope is present
assert "sensor:read" in payload["scope"].split()
// Node.js — jose
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(new URL("https://iotman.io/.well-known/jwks.json"));
const { payload } = await jwtVerify(token, JWKS, {
  audience: "<your-workspace-slug>",
});
assert(payload.scope.split(" ").includes("sensor:read"));

Critical: always verify both aud (workspace UUID) and the required scope. Verifying only the signature is insufficient.