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:
| Principal | Who they are | How they authenticate |
|---|---|---|
| Workspace members | IoTMan users who manage the workspace | Passwordless magic link at IoTMan login |
| End-users | External people who consume protected resources | Passwordless magic link, via an OAuth2 Authorization Code + PKCE flow |
| M2M clients | Devices and backend services acting on their own | Client 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.
| Claim | Example value | Meaning |
|---|---|---|
iss | https://iotman.io | Issuer — always IoTMan |
sub | user-uuid or client-uuid | The end-user or M2M client the token was issued to |
aud | ws-slug | The workspace slug that issued the token |
scope | sensor:read video:gold | Space-separated list of granted permissions |
exp | 1753000000 | Expiry (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:
- Name:
sensor:read - Description: Read access to sensor export endpoints
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:
- Enter their email.
- Select scope:
sensor:read. - 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_idmust appear on both calls.send-otpstamps the requesting client onto the OTP row;/oauth2/tokenonly redeems rows whose stamped client matches the calling client. If you callsend-otpwith noclient_idand then try to redeem at/oauth2/token, the token endpoint won't find a usable row and returnsinvalid_grant: "No pending OTP for this email + client_id. Call POST /api/auth/send-otp with the same client_id first."Codes minted bysend-otpwithoutclient_idare 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:read4. 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:
video:gold— access to gold-tier contentvideo:silver— access to silver-tier content
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.jsonExample 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.