Skip to main content
OTPless issues ID tokens as JWTs signed with the RS256 algorithm.
Your backend must verify the signature and claims before trusting the token.

JWKS (Public Keys)

To verify signatures, fetch OTPless public keys from:
  • Recommended (secure): https://otpless.com/.well-known/jwks
These contain the RSA public keys used for verifying RS256 signatures.
You must select the key where kid matches the JWT header.

Example JWT ID Token

{
  "kid": "pk0183",
  "typ": "JWT",
  "alg": "RS256"
}

Payload

{
  "sub": "MO-1xx13cc0bf5341xxxxx6da2xxx43xxx",
  "aud": "PXXXXG1XXXX1NXXYAO",
  "country_code": "+91",
  "auth_time": "1758641886",
  "iss": "https://otpless.com",
  "national_phone_number": "9999999999",
  "phone_number_verified": true,
  "phone_number": "919999999999",
  "exp": 1758622386,
  "iat": 1758622086,
  "token": "xxxx4e11xxx95f1xxxxxa5xxxc38xxd54"
}

Claim Reference

ClaimTypeDescriptionExample
substringSubject identifier — unique ID for the user at OTPless level"MO-1xxxcc0bf..."
audstringAudience — must match your APP_ID"XXXX1PMXXXXx1X9XXXX"
country_codestringCountry dialing code in international format"+91"
auth_timenumberEpoch timestamp when authentication happened1758641886
issstringIssuer — must always equal https://otpless.com"https://otpless.com"
national_phone_numberstringPhone number without country code"9999999999"
phone_number_verifiedbooleanIndicates whether phone number was successfully verifiedtrue
phone_numberstringFull phone number in E.164 format"919999999999"
expnumberExpiration time (epoch seconds) — reject if token expired1758622386
iatnumberIssued-at time (epoch seconds)1758622086
tokenstringOpaque reference token generated by OTPless for one time validate for s2s"8xexxxxx54..."

Verification Checklist

  1. Signature
    • Verify using RS256 with OTPless JWKS (https://otpless.com/.well-known/jwks).
  2. Issuer (iss)
    • Must equal https://otpless.com.
  3. Audience (aud)
    • Must equal PXXXXG1XXXX1NXXYAO.
  4. Time claims
    • exp > current time (allow ±60s skew).
    • iat and auth_time optional checks.
  5. App-specific checks
    • Ensure phone_number_verified: true before granting sensitive access.
    • Use sub as the stable user identifier.
  6. Algorithm hardening
    • Accept only RS256.
    • Reject tokens with alg: none or unexpected algorithms.

Code Examples

Node.js (Express) — jose

npm i jose
import { createRemoteJWKSet, jwtVerify } from 'jose'

const JWKS_URL = 'https://otpless.com/.well-known/jwks'
const ISSUER = 'https://otpless.com'
const AUDIENCE = 'PXXXXG1XXXX1NXXYAO'

const jwks = createRemoteJWKSet(new URL(JWKS_URL))

export async function verifyOtplessIdToken(idToken: string) {
  const { payload, protectedHeader } = await jwtVerify(idToken, jwks, {
    issuer: ISSUER,
    audience: AUDIENCE,
    algorithms: ['RS256'],
    clockTolerance: 60,
  })
  return { header: protectedHeader, claims: payload }
}

Java (Spring Boot) — Nimbus JOSE + JWT

<dependency>
  <groupId>com.nimbusds</groupId>
  <artifactId>nimbus-jose-jwt</artifactId>
  <version>9.37.3</version>
</dependency>
public class JwtVerifier {
  private final ConfigurableJWTProcessor<SecurityContext> processor;

  public JwtVerifier() throws Exception {
    processor = new DefaultJWTProcessor<>();
    var jwks = new RemoteJWKSet<SecurityContext>(new URL("https://otpless.com/.well-known/jwks"));
    var selector = new JWSVerificationKeySelector<SecurityContext>(JWSAlgorithm.RS256, jwks);
    processor.setJWSKeySelector(selector);

    processor.setJWTClaimsSetVerifier((claims, context) -> {
      if (!"https://otpless.com".equals(claims.getIssuer()))
        throw new BadJWTException("Invalid issuer");
      if (!claims.getAudience().contains("PXXXXG1XXXX1NXXYAO"))
        throw new BadJWTException("Invalid audience");
    });
  }

  public JWTClaimsSet verify(String token) throws Exception {
    return processor.process(token, null);
  }
}

Python (Flask/FastAPI) — authlib

pip install authlib httpx
import time, httpx
from authlib.jose import jwt, JsonWebKey

JWKS_URL = "https://otpless.com/.well-known/jwks"
ISSUER = "https://otpless.com"
AUDIENCE = "PXXXXG1XXXX1NXXYAO"

async def fetch_jwks():
    async with httpx.AsyncClient(timeout=5) as client:
        return (await client.get(JWKS_URL)).json()

async def verify_otpless_id_token(token: str, leeway: int = 60):
    jwks = await fetch_jwks()
    header = jwt.get_unverified_header(token)
    kid = header.get("kid")
    jwk = next((k for k in jwks["keys"] if k["kid"] == kid), None)
    if not jwk:
        raise ValueError("Signing key not found")

    key = JsonWebKey.import_key(jwk)
    claims = jwt.decode(token, key, claims_options={
        "iss": {"essential": True, "values": [ISSUER]},
        "aud": {"essential": True, "values": [AUDIENCE]},
        "exp": {"essential": True},
    }, leeway=leeway)
    claims.validate()
    return claims

Best Practices

  • Cache JWKS (5–15 minutes). Refresh when a kid is not found.
  • Always use HTTPS JWKS endpoint in production for security.
  • Strictly check iss and aud.
  • Allow a small clock skew (30–60s).
  • Reject unexpected algorithms (only RS256).
  • Use phone_number_verified before sensitive operations.
  • Never trust a JWT by simply decoding — always verify signature.
I